diff --git a/AGENTS.md b/AGENTS.md index 99437c689..0c026705f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ It authenticates over HTTPS via `gh`'s credential helper and rewrites the SSH re ## Building on Interchange -Interchange is the standard library for this repo, consumed as published `@intx/*` npm packages pinned at 0.2.2 (`@intx/inference` resolves to the vendored copy in `vendor/intx-inference` — upstream 0.2.2 plus the audited patch set on CL-4352). We never modify or push to the upstream interchange repository. Before writing any new infrastructure — plugins, middleware, utilities, state management, logging, authz, inference, tools — check these packages. +Interchange is the standard library for this repo, consumed as published `@intx/*` npm packages pinned at 0.2.2, except `@intx/inference`, `@intx/types`, and `@intx/storage-isogit`, which resolve to vendored source under `vendor/intx-*` at upstream head (coupled by the reactor's approval-suspend primitive; `@intx/inference` also carries a local patch set). See `docs/VENDORING.md` for what's vendored, from which upstream commit, and the re-sync procedure. We never modify or push to the upstream interchange repository. Before writing any new infrastructure — plugins, middleware, utilities, state management, logging, authz, inference, tools — check these packages. | Package | Covers | |---|---| diff --git a/bun.lock b/bun.lock index 4362bf880..1360d8e94 100644 --- a/bun.lock +++ b/bun.lock @@ -9,10 +9,10 @@ "@intx/authz": "0.2.2", "@intx/inference": "workspace:*", "@intx/log": "0.2.2", - "@intx/storage-isogit": "0.2.2", + "@intx/storage-isogit": "workspace:*", "@intx/tools-lsp": "0.2.2", "@intx/tools-posix": "0.2.2", - "@intx/types": "0.2.2", + "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentui/core": "0.5.1", "@opentui/keymap": "0.5.1", @@ -59,9 +59,33 @@ "@intx/mime": "0.2.2", }, }, + "vendor/intx-storage-isogit": { + "name": "@intx/storage-isogit", + "version": "0.2.2", + "dependencies": { + "@intx/log": "0.2.2", + "@intx/mime": "0.2.2", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "isomorphic-git": "catalog:", + }, + }, + "vendor/intx-types": { + "name": "@intx/types", + "version": "0.2.2", + "dependencies": { + "arktype": "catalog:", + "semver": "catalog:", + }, + "devDependencies": { + "@types/semver": "catalog:", + }, + }, }, "overrides": { "@intx/inference": "workspace:*", + "@intx/storage-isogit": "workspace:*", + "@intx/types": "workspace:*", }, "catalog": { "@types/semver": "^7.7.1", @@ -159,13 +183,13 @@ "@intx/mime": ["@intx/mime@0.2.2", "", { "dependencies": { "@intx/crypto": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29" } }, "sha512-uSrPWqEi8GurKbawtqk3S3KOHi/o1DmxCEeUb3un3fsZC2RkZrhqtqkTpY8a8q5r5gIJCqW/jmi3hyPyjZMakw=="], - "@intx/storage-isogit": ["@intx/storage-isogit@0.2.2", "", { "dependencies": { "@intx/log": "0.2.2", "@intx/mime": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29", "isomorphic-git": "^1.27.2" } }, "sha512-vd7v2LjJgDwoPygEOHOxCPJuL3XlsH9AWj3/aG6vPOKBzEoIq6FOZOOZYnpFIbYSWjW5ftRIXDjmjufe2JRuQQ=="], + "@intx/storage-isogit": ["@intx/storage-isogit@workspace:vendor/intx-storage-isogit"], "@intx/tools-lsp": ["@intx/tools-lsp@0.2.2", "", { "dependencies": { "@intx/agent": "0.2.2", "@intx/log": "0.2.2", "@intx/tools-posix": "0.2.2", "@intx/types": "0.2.2", "vscode-jsonrpc": "^9.0.1", "vscode-languageserver-types": "^3.17.5", "which": "^4.0.0" } }, "sha512-MCng6nK/25B/4EQGKlbyiPjDhOxHC0xNtgqiWd8gyWpTqDJ3zWHNKv3qVGoBrNWN72KkQHxwoQg4wMyfbQZ4lA=="], "@intx/tools-posix": ["@intx/tools-posix@0.2.2", "", { "dependencies": { "@intx/agent": "0.2.2", "@intx/types": "0.2.2" } }, "sha512-SN2TDAotUj/9wj4QdmmcSsO8InujxMFNLn5UGwmKXYSz7yoGcJrFyJlYkhmrJPECRdU86bvflTvgEydxpW0wZg=="], - "@intx/types": ["@intx/types@0.2.2", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-9DYTXLuf6ARsHWOWnRuOaA/+5haM3yJVUhvzlps/eQTH6zjk3CMOlbgKUtBYZ164MhC5IbXCvJTLGyM1grpMqw=="], + "@intx/types": ["@intx/types@workspace:vendor/intx-types"], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -207,6 +231,8 @@ "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], diff --git a/docs/VENDORING.md b/docs/VENDORING.md new file mode 100644 index 000000000..f2e14cae3 --- /dev/null +++ b/docs/VENDORING.md @@ -0,0 +1,119 @@ +# Vendored Interchange packages + +Corbits Code consumes most of Interchange as published `@intx/*` npm +packages. A few packages are instead vendored as source, directly from the +upstream Interchange repository, under `vendor/`. This document is the +authoritative record of what is vendored, from which upstream commit, and +whether it carries local patches. + +## Why vendor instead of install + +The npm registry lags Interchange's own `main` branch, sometimes by weeks. +When a fix or a new primitive on `main` is needed before the next npm +release, the alternative to waiting is vendoring: copying the package's +source directly into this repo as a Bun workspace member, so it resolves at +whatever upstream commit it was last synced to instead of the last +published version. + +Vendored packages are TypeScript source with no build step — Bun loads +`.ts` files natively, so a vendored package's `package.json` `exports` map +points straight at `./src/*.ts` files rather than a `dist/` build. + +## What's vendored + +| Package | Vendor path | Synced from upstream commit | Local patches | +|---|---|---|---| +| `@intx/inference` | `vendor/intx-inference/` | `cd7c5a37747dc39713d1efd24296ea861e6ac82a` | Yes — see `vendor/intx-inference/PATCHES.md` | +| `@intx/types` | `vendor/intx-types/` | `cd7c5a37747dc39713d1efd24296ea861e6ac82a` | None — verbatim | +| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | `cd7c5a37747dc39713d1efd24296ea861e6ac82a` | None — verbatim | + +All three were synced together in one pass because they are not +independently upgradable: the reactor's approval-suspend primitive (upstream +commit `06d39dc6`, "Suspend the reactor on an ask authz decision") spans all +three packages in a single upstream change — `@intx/inference`'s +`authz-extension.ts` and `reactor.ts` return and dispatch a `PendingOperation` +type that lives in `@intx/types`'s `runtime.ts`, and `@intx/storage-isogit`'s +`store.ts` persists it. Vendoring `@intx/inference` at a newer commit than +`@intx/types` (or vice versa) does not typecheck by construction, since the +inference package's exported function signatures reference types that only +exist in the newer `@intx/types`. + +The remaining Interchange packages this repo consumes (`@intx/authz`, +`@intx/agent`, `@intx/tools-posix`, `@intx/log`) stay on published npm +releases as of this writing. Whether any of those has the same +cross-package coupling is a question for whoever vendors them next, not +answered here. + +A `version` field of `"0.2.2"` in a vendored package's `package.json` is a +carried-over convention from the original `@intx/inference` vendoring, not a +claim about what's actually checked out — the vendored source can be (and +generally is) well ahead of that version number. The commit hash in the +table above is the only thing that reflects actual content; the `version` +field exists only because some tooling expects `package.json` to declare +one. + +## How a vendored package resolves + +Root `package.json`: +- `workspaces` lists each `vendor/intx-*` directory as a workspace member. +- `overrides` pins the package name to `workspace:*`, so every transitive + consumer (including other published `@intx/*` packages that declare a + dependency on it) resolves to the vendored copy instead of installing + their own nested copy from npm. +- The package's own entry in root `dependencies` reads `"workspace:*"` + rather than a version string. + +This is also what collapses a duplicate-dependency problem: before +`@intx/types` was vendored, every published `@intx/*` package we consumed +carried its own nested `arktype` install (pinned to whatever `arktype` minor +version was current when that package was last published on npm), distinct +from the root's own `arktype` — so an `instanceof` check against a type +constructed by one `arktype` instance silently failed against the other. +Vendoring `@intx/types` (and anything that itself vendors `@intx/types` as +`workspace:*`) removes the nested install; every import of `arktype` under +those packages now resolves to the single root instance. As of this sync, +`bun.lock` shows exactly one `arktype` resolution across the whole tree. + +## Patched vs. verbatim + +`@intx/types` and `@intx/storage-isogit` are verbatim copies of upstream — +no modifications. A diff against any later upstream checkout at the same +paths will show 100% upstream-authored lines. + +`@intx/inference` carries local patches — real fixes not yet present +upstream, not workarounds for something upstream has since fixed. Every +patched location carries a one-line comment naming its entry in +`vendor/intx-inference/PATCHES.md`, so `grep -rn "Locally patched" vendor/intx-inference/src` +finds every divergence, and a diff against a fresh upstream checkout at the +same commit should show ONLY those marked lines changed. + +## Re-syncing a vendored package to a newer upstream commit + +1. In the read-only upstream clone, confirm the commit to sync to and note + its hash for this document's table. +2. For a **verbatim** package (`@intx/types`, `@intx/storage-isogit`): + copy `src/`, `README.md` over the vendored directory's `src/`, + `README.md` (leave `package.json` and `LICENSE` as they are unless the + package's own `package.json` exports or dependencies changed upstream — + diff the two `package.json` files by hand). Run `bun install`, + `bun run typecheck`, `bun run build`, `bun run test`. +3. For a **patched** package (`@intx/inference`): before overwriting + anything, diff the current vendored `src/` against the upstream tag or + commit it was last synced from, to re-derive the exact patch content (do + not trust `PATCHES.md`'s prose alone — diff the code). Then overwrite + `src/` with the new upstream commit's source, and re-apply each patch + from the ledger by hand against the new file shapes. For each patch, + confirm from the new upstream source whether it: (a) still applies + as-is, (b) needs adapting to a changed surrounding shape, or (c) has been + subsumed by an equivalent upstream fix and can be dropped — verify (c) by + reading the new upstream code, never by assumption. Update + `PATCHES.md` to reflect what actually landed, including any patches + dropped as superseded and why. Run the full gate + (`typecheck`/`build`/`test`) and do not consider the sync complete until + it passes clean. +4. Because `@intx/inference`, `@intx/types`, and `@intx/storage-isogit` are + coupled (see above), a re-sync that moves any one of their commit hashes + should move all three together, even if only one had code changes worth + vendoring — otherwise the trio drifts out of the single-commit coherence + this document assumes. +5. Update this document's table with the new commit hash. diff --git a/docs/plans/interchange-vendoring-plan.md b/docs/plans/interchange-vendoring-plan.md new file mode 100644 index 000000000..fbdb53f38 --- /dev/null +++ b/docs/plans/interchange-vendoring-plan.md @@ -0,0 +1,275 @@ +# Interchange vendoring plan + +Read-only research, verified against the real upstream clone at +`/Users/thegreataxios/abklabs/interchange` (HEAD `cd7c5a37`), not the npm +registry. Companion to `docs/plans/interchange-upgrade-assessment.md`, which +covers the original registry-only pass; this document supersedes that one's +ledger and adds the vendoring-scope decision, the approval-primitive +analysis, and the staged execution plan. Nothing in this repo or in the +upstream clone was modified to produce it. Upstream is never modified or +pushed to from this repo — patches live only in `vendor/intx-inference` +here, and are dropped the moment upstream carries the same fix. + +## 1. Why vendor at scale + +We consume Interchange as published `@intx/*` npm packages pinned at +`0.2.2` (2026-07-15). The npm registry has nothing published past `0.2.2` — +upstream `main` is 416 commits and about three weeks ahead with no newer +release. Waiting for upstream releases is not an option if we want the +reactor correctness fixes and the new approval primitive (§4). The only way +to reach upstream head is to vendor the packages as source, the same way we +already vendor `@intx/inference` in `vendor/intx-inference`. This is +approved: staying cutting-edge matters more than staying on published +releases, provided the vendoring is legible (§6) and upstream is never +touched. + +## 2. Verified patch ledger — `vendor/intx-inference` + +Diffed `vendor/intx-inference/src` against `packages/inference/src` at +upstream HEAD (not just `v0.2.2`) for all 9 files the prior assessment +flagged as diverging. + +| File | Patch purpose | Verdict at HEAD | Disposition | +|---|---|---|---| +| `adapter.ts` | `StreamTerminalDetector`/`isStreamTerminal` so OpenAI Responses streaming ends on a semantic completion event instead of hanging on socket close | Absent (`isStreamTerminal`: 0 hits) | **Carry** | +| `assembly.ts` | `resolvedContextTransforms = contextTransforms ?? deps.contextTransforms` fallback, since published `@intx/agent` forwards `deps` verbatim with no dedicated transforms field | Absent, and the surrounding shape is unchanged since `0.2.2` — re-applies cleanly, no rework needed | **Carry** (prior assessment's "needs closer diff" hedge was unnecessary) | +| `errors.ts` / `harness.ts` | `classifyAbortError(reason)` takes the abort reason so callers get `origin` (`user-stop` vs `internal-recovery`) | `classifyAbortError()` still takes 0 args at HEAD, called with 0 args at all 4 sites in `harness.ts` | **Carry** | +| `providers/google-genai-files.ts` | `opts.bytes as unknown as BodyInit` cast to satisfy DOM lib's `BodyInit` vs Node's `Uint8Array` typing | HEAD line 172 still passes `opts.bytes` uncast | **Carry** (worth filing upstream as a real typing gap) | +| `reactor.ts` — `correlatingIds` leak | Wraps the correlated-resume critical section in `try/finally` so the id is cleared on the success path too | Confirmed live leak at HEAD: `tryCorrelate` (`reactor.ts:451-548`) deletes `correlatingIds` on the three failure exits (467, 471, 486) but none of the three success dispatch modes (`redispatch`/`error_result`/`gate-cleared`, 494-547) delete it before returning — unbounded `Set` growth on every successful correlated resume, for the life of the process | **Carry** | +| `sse.ts` | `MAX_LINE_LENGTH` caps the unterminated SSE line buffer at 16 MiB, throws instead of growing unbounded | Absent (0 hits) — OOM vector still open | **Carry** | +| `state.ts` | `deepFreeze` on appended turns + `turnsRevision` counter so persistence skips re-serializing unchanged history on checkpoint | Absent (0 hits) | **Carry** | +| `index.ts` | Re-exports the patches above | Mechanical; re-derives itself once the source patches are re-applied | **Carry** (no independent work) | +| `reactor.ts` — `ephemeralTurns` on `ExtendedInferenceOptions` | Per-call turns appended to the materialized prompt but never persisted, for transient director guidance without touching the cached prefix | Absent everywhere in `packages/inference/src/*.ts` at HEAD, including `director.ts` and `default-director.ts` — no native equivalent exists | **Carry** (prior assessment's "may be superseded by the director split" does not hold) | + +**Count: 9/9 carry, 0 drop, 0 rework.** Every substantive patch (8 of the 9 +rows; `index.ts` is mechanical) is a real fix still absent upstream — not +dead weight from a prior workaround. Re-applying them on any upgrade is not +optional cleanup: skipping them reintroduces a memory leak +(`correlatingIds`), an OOM vector (unbounded SSE line growth), and a hang +(OpenAI Responses streaming never terminating on socket-only close). + +One correction to the prior assessment, framing only, no ledger impact: +`director.ts` is not new since `0.2.2` — it predates it by many commits +(`657618d9`, `efa2f98b` visible in `git log --oneline -- packages/inference/src/director.ts`). +Our vendored `director.ts` is byte-identical to upstream HEAD. None of the 9 +patched files touch `director.ts`, `default-director.ts`, or +`correlation.ts`, so the restructuring in that area doesn't affect re-apply +cost for any of the above. + +## 3. Vendoring scope + +Upstream has 30 packages (`ls packages/` at HEAD; prior assessment's "~31" +was close). We consume 7: `@intx/authz`, `@intx/inference`, `@intx/agent`, +`@intx/tools-posix`, `@intx/storage-isogit`, `@intx/log`, `@intx/types`. + +**Recommendation: vendor all 7 as source, not just `@intx/inference`.** +Mixing a vendored-at-head `@intx/inference` against published-at-0.2.2 +`@intx/agent`/`@intx/types`/`@intx/storage-isogit` is not viable — +`@intx/inference` HEAD's reactor suspend/resume path (§4) depends on the +`PendingOperation` type added to `packages/types/src/runtime.ts` and a +`store.ts` change in `@intx/storage-isogit`, both landed in the same commit +(`06d39dc6`) as the `@intx/inference` change. Vendoring `@intx/inference` +alone without its type and storage counterparts would leave the suspend +path referencing types that don't exist in our pinned `@intx/types`. +`@intx/authz`, `@intx/tools-posix`, `@intx/log` have no direct coupling to +the suspend/resume change and could stay published — but since they're +small and the whole point is coherence with head, vendor them too rather +than tracking two different sync cadences. + +### Does vendoring resolve the arktype duplication (root cause of `instanceof` narrowing breaking)? + +**Yes, and it's confirmed as a real duplication today, not a hypothetical.** +Root `corbits-code/package.json:72` pins `"arktype": "^2.2.3"` directly. +`bun.lock:222` resolves that to `arktype@2.2.3` with +`@ark/schema@0.56.2`/`@ark/util@0.56.2`. But every published `@intx/*` +package we consume (`bun.lock:606-616`: `@intx/agent`, +`@intx/inference-discovery`, `@intx/inference-testing`, `@intx/mime`, +`@intx/storage-isogit`, `@intx/types`) carries its own **nested** +`arktype@2.2.0` with `@ark/schema@0.56.0`/`@ark/util@0.56.0` — a distinct +minor version with distinct `@ark/*` internals, hence a distinct +`ArkErrors`/type prototype from the root's. Any `instanceof` check against a +type constructed by the nested instance and evaluated against the root's +`arktype` (or vice versa) silently returns `false`. Upstream's own +`package.json` files declare `"arktype": "catalog:"` (a workspace catalog +reference, not a literal version) for `inference`, `agent`, +`storage-isogit`, `types` — so at upstream HEAD, within the interchange +monorepo itself, there is exactly one `arktype` instance shared by every +package. + +Vendoring these packages as source under our own workspace, rather than +installing them as independent npm packages, collapses them onto our root +`arktype` — there is no nested `node_modules/@intx/*/node_modules/arktype` +once the package isn't installed from the registry, so every import +resolves to the single root instance. That removes the duplication +entirely, provided the root `arktype` version stays compatible with what +the vendored source expects (the source doesn't pin a version at all once +it's ours — only `import "arktype"` calls remain, resolved via our root +`package.json`). + +## 4. What changes for us — the approval primitive + +Commit `06d39dc6` ("Suspend the reactor on an ask authz decision", +2026-07-16, one day after our `v0.2.2` pin, +659/-92 across 4 files) changes +`packages/inference/src/authz-extension.ts` so the `ask` authz effect no +longer blocks with an error — it suspends the reactor. On +`result.effect === "ask"` it mints a `correlationId`, computes a +`timeoutAt` from `DEFAULT_APPROVAL_TIMEOUT_MS` (1 hour, overridable via +`opts.approvalTimeoutMs`), builds a `gateId` (`pending-${correlationId}`), +and returns `{ type: "suspend", gate: {...}, pendingOp }` instead of an +error string. `packages/types/src/runtime.ts` adds the `PendingOperation` +type and extends `BeforeToolExtension`'s return contract with the `suspend` +variant. `packages/inference/src/reactor.ts` wires the suspend action into +the dispatch loop — this is the same function (`tryCorrelate`) audited for +the `correlatingIds` leak in §2, so adopting this primitive and carrying our +leak fix are the same piece of work, not two. `packages/storage-isogit/src/store.ts` +persists the pending-operation record. Three follow-ups refine it through +early August: `c5d31268` (capture suspended tool call on park), `7310711f` +(single-use approval bypass), `f563ab60` (approval snapshot construction). + +**Our current architecture was built without this primitive and does not +call it.** `src/permission/gate.ts` (625 lines) is our own durable +pending-approval gate — it owns the block/ask decision, has its own +headless-fallback policy (unresolved ask + no operator attached → denial, +`gate.ts:198`), and its own command-deny path stricter than authz grants +(`gate.ts:387`). `src/permission/queue.ts` and `store.ts` are our own +queue/persistence for pending approvals. `src/permission/classify.ts` +defines our own `Tier = "allow" | "ask"` classifier, independent of +`@intx/authz` grants. None of these call `createAuthzExtension`, +`PendingOperation`, or `ApprovalSnapshot` — a repo-wide grep for those +symbols under `src/permission/` and `src/agent/director.ts` returns zero +hits. `src/agent/director.ts` (808 lines) has its own `ask_operator` tool +and task/goal state machine, with no reference to reactor-level suspend. + +This is not incremental overlap — it is a parallel mechanism, built because +upstream lacked one. Adopting upstream's primitive concretely means: + +- Replacing `gate.ts`'s pending-record bookkeeping with the reactor-level + `PendingOperation`/correlation-id flow the suspend action returns. +- Rewiring `director.ts`'s ask-handling to consume `suspend` reactor + actions (via `createAuthzExtension`) instead of managing its own queue. +- Deciding whether `classify.ts`'s allow/ask tiering still sits above + authz grants as a pre-filter, or gets re-expressed as authz policy that + resolves to `ask` and lets the reactor's suspend path own the rest. +- Deciding what happens to our stricter-than-authz command-deny path + (`gate.ts:387`) and headless-denial policy (`gate.ts:198`) — neither has + an upstream equivalent; both need to be re-homed somewhere in the new + flow, not dropped silently. + +Upstream's `director.ts`/`default-director.ts`/`correlation.ts` are +`@intx/inference`-internal reactor plumbing (a pure `ReactorCapabilities` +factory and the built-in `DefaultDirector`), not something our +`src/agent/director.ts` subclasses or parallels in shape — there is no +"split" on our side to reconcile against theirs. The delta that matters for +us is entirely the suspend/resume path in `reactor.ts`, not `director.ts`'s +structure. + +`@intx/inference-discovery` exists at `packages/inference-discovery/` and is +confirmed (per its `README.md` and `src/index.ts`) to be a capture/replay +test rig — `ProviderPlugin`, `runCapture`, a CI guard that aborts if `CI` is +set, writing `request.json`/`response.{json,sse}` bundles for +`@intx/inference-testing` to replay — not a runtime model registry. Its +`catalog` submodule (`Capability`, `INTENTS`, `SUPPORT_MATRIX`, +`catalogCapabilitiesFor`) is the right shape to answer "what +provider/model/capability triples exist" for onboarding (CL-5499/5494), but +whether it's meant to be imported at CLI runtime versus only as a +fixture-seeding data source for tests is still unconfirmed and out of scope +for this vendoring pass. + +## 5. Staged, independently-landable sequence + +Each stage lands on `main`, keeps the build/typecheck/test gates green, and +is independently releasable — no stage depends on a later one being merged +first for main to stay shippable. + +**Correction, post-implementation:** this claim does not hold for stages 2 +and 3. Vendoring `@intx/types` alone (stage 2) does not typecheck against +the already-vendored `@intx/inference` (which still targets the old, +npm-published type shapes) — the approval-suspend primitive's +`PendingOperation` type and `BeforeToolDecision` return contract changed +shape between the npm-published `@intx/types` and upstream head, and +`@intx/inference`'s vendored source is pinned to the old shapes until it is +also re-synced. The two packages are typed against each other via the same +upstream commit (`06d39dc6`) and cannot be split across two separately +landable PRs without an intermediate broken-build state. Stages 2 and 3 were +merged into a single PR as a result; see `docs/VENDORING.md` for the +resulting vendoring record. + +1. **Approval-primitive RFC** (CL-5683). Read `06d39dc6` and its three + follow-ups in full, decide whether `src/permission` adopts the reactor + suspend primitive, adapts it, or keeps the current design with an + explicit reason. This is a design decision, not code — it gates stage 4 + and must land before CL-5643 builds anything on the current "ask blocks + with an error" model. +2. **Vendor `@intx/types` and `@intx/storage-isogit` at head**, replacing + the published npm installs. Smallest-blast-radius packages, no patch + history to carry, and they're the dependency floor everything else in + this sequence needs (`PendingOperation` lives in `@intx/types`). +3. **Vendor `@intx/inference` at head**, re-applying all 9 carried patches + from §2 against the new file shapes. This is the highest-risk stage — + budget for it as its own review pass, not a mechanical bump. Confirms or + fixes the arktype duplication (§3) once `@intx/types` is also vendored + (stage 2). +4. **Vendor `@intx/agent`, `@intx/authz`, `@intx/tools-posix`, `@intx/log` + at head**, completing the coherent set. Lower risk than stage 3 — these + have no known local patches. +5. **Adopt the approval primitive** in `src/permission` and + `src/agent/director.ts`, per the design from stage 1. Depends on stages + 2-4 landing so `createAuthzExtension`'s suspend path and + `PendingOperation` are actually available. This is the stage CL-5643 + should be built against, not the current gate. +6. **Docs and patch-ledger process** (§6) — can land any time after stage 3 + establishes the vendoring pattern for `@intx/inference`; earlier is + better since it documents the pattern the later stages follow. + +`@intx/inference-discovery` (onboarding catalog, CL-5499/5494) is +deliberately not in this sequence — it needs the runtime-vs-test-fixture +question answered first (§4, last paragraph), and isn't required by the +approval primitive or the arktype fix. + +## 6. Docs plan + +Once vendoring is at the scale of 7 packages instead of 1, the existing +informal pattern (a single `vendor/intx-inference` directory with patches +implied by diffing) stops scaling. `docs/` needs: + +- **`docs/VENDORING.md`** (new): what's vendored and why, one row per + package (`@intx/types`, `@intx/inference`, etc.) with vendor path, source + commit/tag it was synced from, and whether it carries local patches. + Explains the re-sync procedure: how to pull a new upstream commit into a + vendored package, how to re-run the patch ledger check, what "coherent" + means when only some packages have moved. +- **Patch ledger lives in-repo, not only in `docs/plans/`**: a + `vendor/intx-inference/PATCHES.md` (or equivalent per vendored package) + listing each local patch, the file(s) it touches, why it exists, and the + upstream issue/commit to watch for it landing. `docs/plans/*.md` is a + point-in-time investigation; the ledger that must stay current belongs + next to the code it describes, linked from `docs/VENDORING.md`. + `docs/plans/interchange-vendoring-plan.md` (this file) becomes historical + once the stages land — `docs/VENDORING.md` is the document that stays + current after. +- **Vendored-and-patched vs. vendored-verbatim**: make this visible at a + glance — e.g. a patched file carries a header comment naming the patch + entry in `PATCHES.md`; `docs/VENDORING.md`'s table marks each package + patched/verbatim. Anyone diffing a vendored file against upstream should + be able to tell in one grep whether a divergence is intentional (documented + patch) or accidental (missed re-sync). +- **`AGENTS.md`'s "Building on Interchange" table** gets a one-line pointer + to `docs/VENDORING.md` once it exists, so agents checking "does Interchange + already have this" land on the sync-state doc, not just the package list. + +## Recommendation on sequencing against TUI stabilisation + +Stage 1 (the approval-primitive RFC, CL-5683) should happen now, in +parallel with TUI work — it's a read-and-decide task, not implementation, +and it directly gates whether CL-5643 is safe to start. Stages 2-4 (the +vendoring bump itself) should follow the existing recommendation: after the +current TUI stabilisation work, not interleaved with it, since stage 3 in +particular is a real review-weight change to the reactor's core file. +Stage 5 (adopting the primitive in `src/permission`) should not start until +stage 1's decision is made and stages 2-4 are on `main` — building against +the current gate in the meantime is the throwaway-work risk the original +assessment flagged for CL-5643. + +Nothing in this document was implemented. Vendoring at this scope requires +the same operator approval given for the original `@intx/inference` +vendoring before any stage begins landing code. diff --git a/package.json b/package.json index 279ceed37..665c3bef4 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,14 @@ "workspaces": [ ".", "./vendor/intx-inference", + "./vendor/intx-types", + "./vendor/intx-storage-isogit", "./packages/*" ], "overrides": { - "@intx/inference": "workspace:*" + "@intx/inference": "workspace:*", + "@intx/types": "workspace:*", + "@intx/storage-isogit": "workspace:*" }, "catalog": { "@types/semver": "^7.7.1", @@ -61,10 +65,10 @@ "@intx/authz": "0.2.2", "@intx/inference": "workspace:*", "@intx/log": "0.2.2", - "@intx/storage-isogit": "0.2.2", + "@intx/storage-isogit": "workspace:*", "@intx/tools-lsp": "0.2.2", "@intx/tools-posix": "0.2.2", - "@intx/types": "0.2.2", + "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentui/core": "0.5.1", "@opentui/keymap": "0.5.1", diff --git a/src/agent/context-estimate.ts b/src/agent/context-estimate.ts index 919e323f7..90b97f4d0 100644 --- a/src/agent/context-estimate.ts +++ b/src/agent/context-estimate.ts @@ -69,6 +69,8 @@ export function estimateContentBlockTokens(block: ContentBlock): number { (block.stderr?.length ?? 0) + (block.abortReason?.length ?? 0), ); + case "safety_rating": + return estimateTokensFromChars(block.blockReason.length); } } diff --git a/src/perf/reactor-spans.ts b/src/perf/reactor-spans.ts index dc6884c61..4fef21126 100644 --- a/src/perf/reactor-spans.ts +++ b/src/perf/reactor-spans.ts @@ -40,7 +40,7 @@ const FIRST_TOKEN_TYPES: ReadonlySet = new Set([ "inference.code_execution.start", "inference.code_execution.delta", "inference.code_execution.result", - "inference.thinking.signature", + "inference.block.signature", "inference.thinking.redacted", ]); diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts index 1ef11a251..01aef3481 100644 --- a/src/provider/codex-responses-adapter.ts +++ b/src/provider/codex-responses-adapter.ts @@ -405,7 +405,7 @@ export function parseResponse( }); } events.push({ - type: "inference.thinking.signature", + type: "inference.block.signature", seq, data: { signature: tagSignature(source.provider, item["encrypted_content"] as string), index }, }); @@ -467,6 +467,17 @@ const RESPONSES_TERMINAL_EVENTS = new Set([ "response.done", ]); +// The Responses adapters in this file always request `stream: true` +// (buildRequest sets it unconditionally), so a non-streaming JSON body +// reaching the harness means the response kind was misdetected or the +// provider ignored the streaming request — a protocol violation, not a +// supported code path to parse. +export function parseJSONResponse(): never { + throw new ProtocolMismatchError( + "responses adapter: received a non-streaming JSON response, but this adapter always requests stream: true", + ); +} + export function isResponsesStreamTerminal(sseData: string): boolean { let parsed: unknown; try { @@ -489,6 +500,7 @@ export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAd return { buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source), + parseJSONResponse, isStreamTerminal: isResponsesStreamTerminal, }; } diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts index f99962526..b8ce9178e 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/grok-responses-adapter.ts @@ -15,7 +15,12 @@ import { XAI_CLIENT_VERSION, XAI_USER_AGENT, } from "../auth/xai/constants.js"; -import { createResponsesBlockIndexer, parseResponse, signatureForModel } from "./codex-responses-adapter.js"; +import { + createResponsesBlockIndexer, + parseJSONResponse, + parseResponse, + signatureForModel, +} from "./codex-responses-adapter.js"; // Adapter for the grok-cli OAuth proxy (cli-chat-proxy.grok.com), which serves // the OpenAI Responses API at /v1/responses. The request shape mirrors the grok @@ -180,5 +185,6 @@ export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAda return { buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER), + parseJSONResponse, }; } diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 2234d1681..9e42dd808 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -37,7 +37,7 @@ describe("openai-compatible adapter image input", () => { const body = JSON.parse(built.body) as { messages: Array<{ content: unknown }> }; expect(body.messages[0]?.content).toEqual([ - "what is this?", + { type: "text", text: "what is this?" }, { type: "image_url", image_url: { url: "data:image/png;base64,aW1hZ2U=" } }, ]); }); diff --git a/src/provider/openai-responses-adapter.ts b/src/provider/openai-responses-adapter.ts index c9d7548d4..cb05e2c59 100644 --- a/src/provider/openai-responses-adapter.ts +++ b/src/provider/openai-responses-adapter.ts @@ -12,6 +12,7 @@ import type { import { createResponsesBlockIndexer, isResponsesStreamTerminal, + parseJSONResponse, parseResponse, signatureForModel, } from "./codex-responses-adapter.js"; @@ -169,6 +170,7 @@ export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderA return { buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER), + parseJSONResponse, isStreamTerminal: isResponsesStreamTerminal, }; } diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 6f588a596..2e9073812 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -324,7 +324,7 @@ describe("codex-responses parseResponse", () => { ]); expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { index: 0 } }); expect(out[1]).toMatchObject({ - type: "inference.thinking.signature", + type: "inference.block.signature", data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB"), index: 0 }, }); }); @@ -339,7 +339,7 @@ describe("codex-responses parseResponse", () => { expect(out).toHaveLength(2); expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { token: "", index: 0 } }); expect(out[1]).toMatchObject({ - type: "inference.thinking.signature", + type: "inference.block.signature", data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC"), index: 0 }, }); }); diff --git a/tests/unit/codex-sse-fixtures.test.ts b/tests/unit/codex-sse-fixtures.test.ts index b675caffa..c6649a922 100644 --- a/tests/unit/codex-sse-fixtures.test.ts +++ b/tests/unit/codex-sse-fixtures.test.ts @@ -72,7 +72,7 @@ describe("codex-sse fixtures (golden parse)", () => { "inference.thinking.delta", // output_item.added reasoning (empty pre-register) "inference.thinking.delta", // "consider " "inference.thinking.delta", // "options" - "inference.thinking.signature", + "inference.block.signature", "inference.text.delta", // "I will " "inference.text.delta", // "check." "inference.tool_call.start", @@ -95,7 +95,7 @@ describe("codex-sse fixtures (golden parse)", () => { data: { token: "options", index: 0 }, }); expect(out[3]).toMatchObject({ - type: "inference.thinking.signature", + type: "inference.block.signature", data: { signature: tagSignature(SOURCE.provider, "ENC_FIXTURE_BLOB_NOT_REAL"), index: 0 }, }); expect(out[4]).toMatchObject({ diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md new file mode 100644 index 000000000..fb3fce3c0 --- /dev/null +++ b/vendor/intx-inference/PATCHES.md @@ -0,0 +1,145 @@ +# Patch ledger — vendor/intx-inference + +Every divergence from the upstream Interchange source at the commit recorded +in `docs/VENDORING.md`. Each entry is a real bug fix or capability upstream +does not carry; none is a workaround for something upstream has since fixed +(each was re-verified against upstream HEAD when this package was last +synced). A patched file carries a one-line `Locally patched — see +vendor/intx-inference/PATCHES.md#` comment at each patched location so +a diff against upstream shows exactly which lines are ours. + +To re-derive this ledger after a re-sync: diff the pre-sync `src/` against +the new upstream checkout at the same paths; every hunk that survives the +diff is a patch that needs re-applying (or, if upstream has since absorbed +the same fix, dropping — verify by reading the new upstream code, not by +assuming). + +## adapter.ts + +Adds `StreamTerminalDetector`/`ProviderAdapter.isStreamTerminal`. The OpenAI +Responses protocol marks completion with a semantic `response.completed` +event and holds the connection open rather than closing the socket or +sending `[DONE]`; without this, a client reading the stream hangs waiting for +a socket close that never comes. Consumed by `harness.ts`'s SSE loop. + +## assembly.ts + +Resolves `contextTransforms` from either the direct assembly config value or +`deps.contextTransforms` (`resolvedContextTransforms = contextTransforms ?? +deps.contextTransforms`). The published `@intx/agent` forwards `deps` into +reactor assembly verbatim and exposes no dedicated field for transforms; +riding `deps` reaches the vendored assembly without requiring a change to +the published package. + +## errors.ts + +`classifyAbortError` takes an optional `reason` argument and carries it as +`raw: { origin: reason }` on the returned `InferenceError`. `reason` mirrors +`AbortSignal.reason` from the send path (e.g. `user-stop` / +`internal-recovery`), giving callers the abort's origin instead of an +undifferentiated "inference aborted". Called with `signal?.reason` from all +four abort-check sites in `harness.ts`. + +## harness.ts + +Three independent fixes: + +- **Dependencies.contextTransforms** — carries the field `assembly.ts` + reads off `deps` (see above). +- **`classifyAbortError(signal?.reason)`** — passes the abort reason through + at all four sites that classify an aborted signal. +- **Inactivity timer armed only on semantic progress** — the watchdog used to + re-arm on every raw SSE chunk; a provider that sends keep-alive bytes + forever without a terminal event never tripped it, pinning the caller + indefinitely. Now it re-arms only when `adapter.parseResponse` actually + produces events from a chunk. +- **`isStreamTerminal` consulted in the SSE loop** — stops reading once + `adapter.isStreamTerminal?.(sseData)` returns true, for protocols whose + end-of-turn is a semantic event rather than `[DONE]` or socket close. +- **`runInference`'s commitment-boundary streaming redesign** — the + published wrapper buffers an entire attempt and flushes it only once the + attempt's terminal shape (done/error) is known, which means no event + reaches the caller until the whole response has arrived even on a + successful first attempt. The vendored version streams every event to the + caller as it arrives once the attempt "commits" (its first content-bearing + event — the first text/thinking delta, tool call, image, etc.); only the + handful of pre-commit metadata events (`inference.start`, + `inference.usage`) are buffered, so retry stays possible up to the first + real token without holding a whole response in memory. A retryable failure + after commitment can no longer discard already-streamed output, so retry + is suppressed there and the error surfaces on the live stream. See + `isCommitting` and the docblock on `runInference`. + +## reactor.ts + +Five independent fixes, all inside `tryCorrelate` / the cycle-commit path: + +- **`correlatingIds` leak on every successful correlated resume** — the + in-flight marker was deleted on the three failure exits of + `tryCorrelate` but never on the three success dispatch paths + (`redispatch` / `error_result` / `gate-cleared`), leaking one `Set` entry + per correlated message for the life of the process. Wrapped the whole + critical section in `try/finally` so every exit clears it. +- **`ExtendedInferenceOptions.ephemeralTurns`** — turns appended to the + materialized prompt for one inference call only, never written to durable + history, so transient director guidance does not touch the cached + transcript prefix. No native equivalent exists upstream. +- **Checkpoint after a tool cycle that appends to history** — `executeTools` + now calls `commitCycle()` when `addToHistory` is true, so an interrupt + that rebuilds the agent from the store reloads the completed tool + exchange instead of losing an uncommitted tool turn (context previously + committed only at cycle terminals). +- **`afterCheckpoint` fires only for a director-requested checkpoint** — the + hasWork-only auto-commit after `executeTools` is internal durability + plumbing, not a checkpoint the caller asked for; without gating on + `hasOverride` (`pendingMessage !== null`), a director that checkpoints in + a later `decide()` call got `afterCheckpoint` invoked twice for what is, + from its perspective, a single checkpoint. +- **Skip re-serializing unchanged history on checkpoint** — `commitCycle` + now compares `stateManager.getTurnsRevision()` against the revision most + recently written and skips `contextStore.writeTurns` when nothing + changed, avoiding an O(history) re-serialize (including historical + tool-output blobs) on every checkpoint. + +The `void track(p)` → `track(p)` change at three call sites removes a +redundant `void` operator with no behavioral effect (kept from the prior +sync for consistency). + +Two 0.2.2-era patches are **not carried** because upstream HEAD has already +absorbed the underlying fix: an unhandled-rejection guard around +`tryCorrelate` in `deliver()` (upstream's `deliver()` now wraps the whole +correlation dispatch in try/catch and routes failures through +`closeMessageRun`, superseding the vendored version), and a reactor-level +`inference.retry` emission around same-source retry/failover (upstream +moved retry entirely into `harness.ts`'s `runInference` wrapper, which now +emits `inference.retry` itself before the commitment boundary — see +harness.ts above; a reactor-level emission would double the event). + +## sse.ts + +`MAX_LINE_LENGTH` (16 MiB) caps the unterminated SSE line buffer and throws +instead of growing unbounded — an unbounded run of bytes with no newline is +indistinguishable from a stuck or hostile stream and would otherwise OOM the +process. + +## state.ts + +`deepFreeze`s appended turns and tracks a `turnsRevision` counter so +`ReactorState.snapshot()`'s `turns` becomes a lazy, memoized getter instead +of a `structuredClone` on every director decision. High-frequency events +(`tool.done`, `inference.error`) reach directors that never inspect `turns`, +so the prior eager deep-clone made per-event cost scale with session length. +`getTurnsRevision()` also backs `reactor.ts`'s checkpoint-skip optimization +above. + +## index.ts + +Re-exports `ExtendedInferenceOptions` from `reactor.ts` (mechanical; follows +that file's patch). + +## providers/google-genai-files.ts + +Casts `opts.bytes as unknown as BodyInit` — DOM lib's `BodyInit` type is +narrower than Node's `Uint8Array` typing, but `fetch` accepts the bytes at +runtime. Worth filing upstream as a real typing gap rather than carrying +indefinitely. diff --git a/vendor/intx-inference/package.json b/vendor/intx-inference/package.json index 6a198d85e..860512c67 100644 --- a/vendor/intx-inference/package.json +++ b/vendor/intx-inference/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@intx/log": "0.2.2", - "@intx/types": "0.2.2", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/vendor/intx-inference/src/adapter.test.ts b/vendor/intx-inference/src/adapter.test.ts index 6d88d66bf..c41734e3d 100644 --- a/vendor/intx-inference/src/adapter.test.ts +++ b/vendor/intx-inference/src/adapter.test.ts @@ -10,6 +10,7 @@ function createStubAdapter(): ProviderAdapter { return { buildRequest: () => ({ url: "", headers: {}, body: "" }), parseResponse: () => [], + parseJSONResponse: () => [], }; } @@ -40,6 +41,38 @@ describe("createAdapterRegistry", () => { expect(seen).toEqual([source]); }); + test("forwards the quirks bag to the factory untouched", () => { + const seen: unknown[] = []; + const registry = createAdapterRegistry({ + test: (_source, quirks) => { + seen.push(quirks); + return createStubAdapter(); + }, + }); + + const quirks = { some: "bag" }; + registry.resolve(createSource("test"), quirks); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(quirks); + }); + + test("passes undefined to the factory when the quirks bag is omitted", () => { + const seen = { called: false, quirks: "sentinel" as unknown }; + const registry = createAdapterRegistry({ + test: (_source, quirks) => { + seen.called = true; + seen.quirks = quirks; + return createStubAdapter(); + }, + }); + + registry.resolve(createSource("test")); + + expect(seen.called).toBe(true); + expect(seen.quirks).toBeUndefined(); + }); + test("invokes the factory fresh on every resolve", () => { let calls = 0; const registry = createAdapterRegistry({ diff --git a/vendor/intx-inference/src/adapter.ts b/vendor/intx-inference/src/adapter.ts index a52c8eeea..0e73fd713 100644 --- a/vendor/intx-inference/src/adapter.ts +++ b/vendor/intx-inference/src/adapter.ts @@ -38,6 +38,28 @@ export type RequestBuilder = ( // adapter-detected failures can surface is `ProtocolMismatchError`. export type ResponseParser = (sseData: string) => InferenceEvent[]; +// A JSON response parser converts a complete non-streaming response body (a +// single `application/json` document) into the same internal inference events +// the streaming `ResponseParser` produces. It exists for providers that answer +// with a buffered JSON body instead of an SSE stream. +// +// It returns the SAME `InferenceEvent` vocabulary as `parseResponse` — +// text/thinking/refusal deltas, tool_call.*, usage, citation, safety_rating, +// image_output, code_execution.* — re-expressing the whole response in the +// harness's delta/marker protocol (e.g. the entire assistant text as a single +// text.delta). It is not a new "whole message" event shape: the harness runs +// the returned events through the same accumulator as the SSE path, and that +// switch silently drops event types it does not model, so an invented event +// shape yields a silently-empty turn. +// +// Every delta event MUST carry an `index` — the harness's `requireIndex` +// throws otherwise, exactly as on the SSE path — and the parser synthesizes +// indices the same way its SSE sibling does. It MUST emit `inference.usage` +// from the body's usage object, or the harness synthesizes zero token counts. +// Like `ResponseParser` it MAY throw only `ProtocolMismatchError`; no other +// throw type is permitted. +export type JSONResponseParser = (body: string) => InferenceEvent[]; + // An adapter pairs a request builder with a response parser. Registration // is a map keyed by provider identifier — no class hierarchy required. @@ -57,11 +79,14 @@ export type PacingExtractor = (headers: Headers) => number | undefined; // `response.completed` event and holds the connection open, so a client that // waits for socket close hangs. Adapters for those protocols implement this so // the harness stops reading once the terminal event is processed. +// +// Locally patched — see vendor/intx-inference/PATCHES.md#adapter-ts export type StreamTerminalDetector = (sseData: string) => boolean; export type ProviderAdapter = { buildRequest: RequestBuilder; parseResponse: ResponseParser; + parseJSONResponse: JSONResponseParser; extractRetryAfterMs?: RetryAfterExtractor; extractPacingDelayMs?: PacingExtractor; // When present, the harness stops reading the SSE stream after processing @@ -72,14 +97,25 @@ export type ProviderAdapter = { // Builds a fresh adapter for one inference call. Invoked per call so the // returned adapter's per-request parser state never leaks across calls. -export type AdapterFactory = (source: LastCycleSource) => ProviderAdapter; +// +// `quirks` is an opaque per-source bag of provider-specific accommodations, +// passed as a sibling of `source` rather than a field on it: `source` is the +// slim `LastCycleSource` descriptor that rides on every usage event, whereas +// quirks are deployment configuration supplied at instantiation. The bag is +// opaque to everything above the factory; interpreting and validating it is +// the factory's own responsibility. It is optional: an absent bag means the +// adapter's default behavior, so callers that have no quirks omit it. +export type AdapterFactory = ( + source: LastCycleSource, + quirks?: unknown, +) => ProviderAdapter; // Resolves an inference source to a provider adapter. Membership is keyed by // the source's `provider` identifier; resolution mints a fresh adapter so the // per-instance stateful parser is isolated per call and per failover attempt. export type AdapterRegistry = { has(provider: string): boolean; - resolve(source: LastCycleSource): ProviderAdapter; + resolve(source: LastCycleSource, quirks?: unknown): ProviderAdapter; }; /** @@ -107,12 +143,12 @@ export function createAdapterRegistry( has(provider: string): boolean { return byProvider.has(provider); }, - resolve(source: LastCycleSource): ProviderAdapter { + resolve(source: LastCycleSource, quirks?: unknown): ProviderAdapter { const factory = byProvider.get(source.provider); if (factory === undefined) { throw new Error(`Unknown inference provider: ${source.provider}`); } - return factory(source); + return factory(source, quirks); }, }; } diff --git a/vendor/intx-inference/src/assembly.test.ts b/vendor/intx-inference/src/assembly.test.ts index 4c538f602..8ef654526 100644 --- a/vendor/intx-inference/src/assembly.test.ts +++ b/vendor/intx-inference/src/assembly.test.ts @@ -221,7 +221,7 @@ function makeRecordingExtension( return { async beforeTool(_call) { trace.push(name); - return undefined; + return { type: "allow" }; }, }; } @@ -716,7 +716,7 @@ describe("createReactorAssembly", () => { const ext: BeforeToolExtension = { async beforeTool() { trace.push("caller-ext"); - return "blocked-by-caller"; + return { type: "block", reason: "blocked-by-caller" }; }, }; diff --git a/vendor/intx-inference/src/assembly.ts b/vendor/intx-inference/src/assembly.ts index d7dcebbc1..7e8d1f269 100644 --- a/vendor/intx-inference/src/assembly.ts +++ b/vendor/intx-inference/src/assembly.ts @@ -19,6 +19,7 @@ import { type ContextTransform, type InferenceSource, type ReactorDirector, + type ToolDefinition, type ToolResultTransform, type ToolRunner, } from "@intx/types/runtime"; @@ -70,6 +71,13 @@ export type ReactorAssemblyConfig = { onEvent: (event: ReactorEmittedEvent) => void; authorize?: AuthzExtensionOptions["authorize"]; + /** + * Tool definitions forwarded to the authz extension so it can build the + * approver-facing snapshot at an `ask` suspension. Only consumed when + * `authorize` is also supplied. Omitting it puts the authz extension in its + * no-snapshot mode; the production edge always supplies the resolved set. + */ + toolDefinitions?: readonly ToolDefinition[]; auditStore?: AuditStore; beforeToolExtensions?: BeforeToolExtension[]; toolResultTransforms?: ToolResultTransform[]; @@ -121,6 +129,7 @@ export function createReactorAssembly( contextStore, onEvent, authorize, + toolDefinitions, auditStore, beforeToolExtensions: callerBeforeToolExtensions, toolResultTransforms: callerToolResultTransforms, @@ -167,6 +176,7 @@ export function createReactorAssembly( ...(auditCollector !== undefined ? { onDecision: (d) => auditCollector.onDecision(d) } : {}), + ...(toolDefinitions !== undefined ? { toolDefinitions } : {}), }) : undefined; @@ -226,6 +236,8 @@ export function createReactorAssembly( // `deps` (the only channel the published `@intx/agent` forwards verbatim). // A direct value wins so callers composing their own assembly are // unaffected by whatever a shared deps object carries. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#assembly-ts const resolvedContextTransforms = contextTransforms ?? deps.contextTransforms; // exactOptionalPropertyTypes is on: only set optional keys when defined. diff --git a/vendor/intx-inference/src/authz-extension.test.ts b/vendor/intx-inference/src/authz-extension.test.ts index 5b5da7f4a..5495304df 100644 --- a/vendor/intx-inference/src/authz-extension.test.ts +++ b/vendor/intx-inference/src/authz-extension.test.ts @@ -6,7 +6,12 @@ import { type AuthzDecision, } from "./authz-extension"; -import type { ToolCall, ReactorState, TokenUsage } from "@intx/types/runtime"; +import type { + ToolCall, + ToolDefinition, + ReactorState, + TokenUsage, +} from "@intx/types/runtime"; function emptyUsage(): TokenUsage { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }; @@ -96,7 +101,7 @@ describe("createAuthzExtension", () => { const result = await ext.beforeTool(makeCall(), makeState(), signal); - expect(result).toBeUndefined(); + expect(result.type).toBe("allow"); const d = getDecision(decisions); expect(d.callId).toBe("call-1"); expect(d.effect).toBe("allow"); @@ -117,7 +122,9 @@ describe("createAuthzExtension", () => { const result = await ext.beforeTool(makeCall(), makeState(), signal); - expect(result).toBe("Denied by policy: tool:bash/invoke"); + expect(result.type).toBe("block"); + if (result.type !== "block") throw new Error("expected block"); + expect(result.reason).toBe("Denied by policy: tool:bash/invoke"); const d = getDecision(decisions); expect(d.effect).toBe("deny"); expect(d.blocked).toBe(true); @@ -127,7 +134,7 @@ describe("createAuthzExtension", () => { expect(d.resolvedBy.id).toBe("grant-2"); }); - test("ask effect blocks with approval message", async () => { + test("ask effect suspends with a minted correlation and pending operation", async () => { const decisions: AuthzDecision[] = []; const ext = createAuthzExtension({ @@ -137,14 +144,138 @@ describe("createAuthzExtension", () => { resolvedBy: null, }), onDecision: (d) => decisions.push(d), + approvalTimeoutMs: 60_000, }); + const before = Date.now(); const result = await ext.beforeTool(makeCall(), makeState(), signal); - - expect(result).toBe("Requires approval: tool:bash/invoke"); + const after = Date.now(); + + expect(result.type).toBe("suspend"); + if (result.type !== "suspend") throw new Error("expected suspend"); + expect(result.gate.type).toBe("approval"); + expect(result.gate.correlationId).toBe(result.pendingOp.correlationId); + expect(result.gate.gateId).toBe(`pending-${result.gate.correlationId}`); + expect(result.pendingOp.gateId).toBe(result.gate.gateId); + expect(result.pendingOp.kind).toBe("approval"); + expect(result.pendingOp.timeoutAt).toBe(result.gate.timeoutAt); + // The deadline is the mint time plus the configured approval timeout. + expect(result.gate.timeoutAt).toBeGreaterThanOrEqual(before + 60_000); + expect(result.gate.timeoutAt).toBeLessThanOrEqual(after + 60_000); + + // A suspended call is neither cleanly blocked nor allowed: the recorded + // decision keeps effect "ask" but is not marked blocked. const d = getDecision(decisions); expect(d.effect).toBe("ask"); - expect(d.blocked).toBe(true); + expect(d.blocked).toBe(false); + expect(d.blockReason).toBeUndefined(); + }); + + const bashToolDef: ToolDefinition = { + name: "bash", + description: "Run a shell command", + inputSchema: { type: "object", properties: { cmd: { type: "string" } } }, + }; + + test("ask builds an approval snapshot from the wired tool definition", async () => { + const ext = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + toolDefinitions: [bashToolDef], + }); + + const call: ToolCall = { + id: "call-1", + name: "bash", + arguments: { cmd: "ls" }, + }; + const result = await ext.beforeTool(call, makeState(), signal); + + expect(result.type).toBe("suspend"); + if (result.type !== "suspend") throw new Error("expected suspend"); + expect(result.pendingOp.approvalSnapshot).toEqual({ + name: "bash", + description: "Run a shell command", + inputSchema: { type: "object", properties: { cmd: { type: "string" } } }, + arguments: { cmd: "ls" }, + }); + }); + + test("ask throws when a wired extension lacks the tool's definition", async () => { + const ext = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + toolDefinitions: [bashToolDef], + }); + + await expect( + ext.beforeTool(makeCall("stripe_charge"), makeState(), signal), + ).rejects.toThrow(/stripe_charge.*wiring defect/s); + }); + + test("ask omits the snapshot when no tool definitions are wired", async () => { + const ext = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + }); + + const result = await ext.beforeTool(makeCall(), makeState(), signal); + expect(result.type).toBe("suspend"); + if (result.type !== "suspend") throw new Error("expected suspend"); + expect(result.pendingOp.approvalSnapshot).toBeUndefined(); + }); + + test("ask throws for every tool when wired with an empty definition set", async () => { + // An empty (but present) toolDefinitions array is the "wired" regime, not + // the "unwired" one: the extension promised a definition for every tool it + // authorizes and has none, so any ask is a wiring defect. This is the + // sentinel distinction between a defined-but-empty map and no map at all. + const ext = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + toolDefinitions: [], + }); + + await expect( + ext.beforeTool(makeCall(), makeState(), signal), + ).rejects.toThrow(/bash.*wiring defect/s); + }); + + test("a one-shot bypass allows a missing-definition call without throwing", async () => { + const ext = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + toolDefinitions: [bashToolDef], + }); + if (ext.grantOneShot === undefined) + throw new Error("expected grantOneShot"); + + // A tool absent from the wired set throws at the ask branch, but the + // one-shot bypass returns allow before the snapshot is built, so a + // re-dispatched approved call is never blocked by the snapshot contract. + ext.grantOneShot("call-9"); + const call: ToolCall = { + id: "call-9", + name: "stripe_charge", + arguments: {}, + }; + const result = await ext.beforeTool(call, makeState(), signal); + expect(result.type).toBe("allow"); }); test("null effect (no matching grants) blocks fail-closed", async () => { @@ -161,7 +292,9 @@ describe("createAuthzExtension", () => { const result = await ext.beforeTool(makeCall(), makeState(), signal); - expect(result).toBe("No matching grants for tool:bash/invoke"); + expect(result.type).toBe("block"); + if (result.type !== "block") throw new Error("expected block"); + expect(result.reason).toBe("No matching grants for tool:bash/invoke"); const d = getDecision(decisions); expect(d.effect).toBeNull(); expect(d.blocked).toBe(true); @@ -199,7 +332,7 @@ describe("createAuthzExtension", () => { }); const result = await ext.beforeTool(makeCall(), makeState(), signal); - expect(result).toBeUndefined(); + expect(result.type).toBe("allow"); }); test("resource format uses tool:{name}", async () => { @@ -245,7 +378,7 @@ describe("createAuthzExtension", () => { }); const result = await ext.beforeTool(makeCall(), makeState(), signal); - expect(result).toBeUndefined(); + expect(result.type).toBe("allow"); }); test("onDecision throwing does not mask authorize error", async () => { @@ -266,4 +399,56 @@ describe("createAuthzExtension", () => { } expect(thrown?.message).toBe("DB connection failed"); }); + + function askResult(): AuthzCallResult { + return { effect: "ask", matchingGrants: [], resolvedBy: null }; + } + + test("grantOneShot lets a matching call bypass its ask gate once", async () => { + const ext = createAuthzExtension({ authorize: async () => askResult() }); + if (ext.grantOneShot === undefined) + throw new Error("expected grantOneShot"); + + ext.grantOneShot("call-1"); + + const first = await ext.beforeTool(makeCall(), makeState(), signal); + expect(first.type).toBe("allow"); + + // The token is consumed on read: the same call re-hits the ask gate. + const second = await ext.beforeTool(makeCall(), makeState(), signal); + expect(second.type).toBe("suspend"); + }); + + test("grantOneShot is keyed on call id, not the tool", async () => { + const ext = createAuthzExtension({ authorize: async () => askResult() }); + if (ext.grantOneShot === undefined) + throw new Error("expected grantOneShot"); + + ext.grantOneShot("call-1"); + + const other = { id: "call-2", name: "bash", arguments: {} }; + const result = await ext.beforeTool(other, makeState(), signal); + expect(result.type).toBe("suspend"); + }); + + test("a stale one-shot token does not silently allow a denied call", async () => { + let effect: "deny" | "ask" = "deny"; + const ext = createAuthzExtension({ + authorize: async () => (effect === "deny" ? denyResult() : askResult()), + }); + if (ext.grantOneShot === undefined) + throw new Error("expected grantOneShot"); + + ext.grantOneShot("call-1"); + + // The grant changed underneath the token: the call now resolves to deny, + // so the token must be dropped rather than bypassing the block. + const blocked = await ext.beforeTool(makeCall(), makeState(), signal); + expect(blocked.type).toBe("block"); + + // The dropped token must not survive to bypass a later ask on the same id. + effect = "ask"; + const suspended = await ext.beforeTool(makeCall(), makeState(), signal); + expect(suspended.type).toBe("suspend"); + }); }); diff --git a/vendor/intx-inference/src/authz-extension.ts b/vendor/intx-inference/src/authz-extension.ts index c036633d4..d5f8f8527 100644 --- a/vendor/intx-inference/src/authz-extension.ts +++ b/vendor/intx-inference/src/authz-extension.ts @@ -7,7 +7,7 @@ // Effects: // allow → tool proceeds // deny → tool blocked -// ask → tool blocked (gate-based approval deferred to a future commit) +// ask → tool suspended (parked awaiting an external approval decision) // null → tool blocked (fail-closed: no grants matched) // // The action is always "invoke" — all tool calls are invocations. If @@ -21,9 +21,20 @@ // logged but swallowed so it cannot interfere with the authorization // decision or mask the original error. -import type { BeforeToolExtension } from "@intx/types/runtime"; +import type { + ApprovalSnapshot, + BeforeToolExtension, + PendingOperation, + ToolDefinition, +} from "@intx/types/runtime"; import type { Effect } from "@intx/types/authz"; +// Default deadline for an approval suspension when the caller does not supply +// one. Matches the reactor's DEFAULT_GATE_TIMEOUT_MS (one hour); the value is +// duplicated rather than imported to avoid a dependency from the pure-policy +// extension onto the reactor module. +const DEFAULT_APPROVAL_TIMEOUT_MS = 3_600_000; + export type AuthzMatchedGrant = { id: string; resource: string; @@ -59,9 +70,23 @@ export type AuthzExtensionOptions = { context: Ctx, ) => Promise; onDecision?: (decision: AuthzDecision) => void; + /** + * Deadline applied to an approval suspension, in milliseconds from the + * moment the `ask` effect is hit. Defaults to `DEFAULT_APPROVAL_TIMEOUT_MS`. + */ + approvalTimeoutMs?: number; + /** + * Tool definitions the extension can be asked to authorize, used to build the + * approver-facing snapshot at the `ask` branch. Presence is a contract: when + * supplied, every tool this extension authorizes must appear here, and an + * `ask` for a tool that does not is a wiring defect that throws. Omitted + * entirely, the extension produces no snapshot — a mode for callers that + * never register a suspension with the hub. + */ + toolDefinitions?: readonly ToolDefinition[]; }; -type BlockEffect = "deny" | "ask" | null; +type BlockEffect = "deny" | null; function formatBlockReason( effect: BlockEffect, @@ -71,8 +96,6 @@ function formatBlockReason( switch (effect) { case "deny": return `Denied by policy: ${resource}/${action}`; - case "ask": - return `Requires approval: ${resource}/${action}`; case null: return `No matching grants for ${resource}/${action}`; } @@ -102,7 +125,29 @@ export function createAuthzExtension( // safe default at this layer. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the inference layer has no domain knowledge to construct a Ctx; callers that need a populated context use closure capture on the authorize function (see @intx/workflow's AuthorizeContext) const emptyContext = Object.freeze({}) as Ctx; + + // One-shot bypass tokens, keyed on ToolCall.id. A token authorizes a single + // re-dispatch of an already-approved call to skip the `ask` gate it would + // otherwise re-hit. Held in memory only, within the resumed reactor cycle + // that grants and consumes it: a durable allow would outlive the cycle and + // defeat the one-shot intent, and a crash between grant and consume simply + // re-drives from the durable log and re-grants. + const approvedOnce = new Set(); + + // Name → definition lookup for building the approval snapshot at the `ask` + // branch. `undefined` (not merely empty) means the caller wired no tool + // definitions and wants no snapshot; a defined map means every authorizable + // tool must be present, so a lookup miss is a wiring defect that throws. The + // sentinel keeps those two contracts distinguishable at the lookup site. + const toolDefinitionsByName = + opts.toolDefinitions !== undefined + ? new Map(opts.toolDefinitions.map((def) => [def.name, def])) + : undefined; + return { + grantOneShot(id) { + approvedOnce.add(id); + }, async beforeTool(call) { const resource = `tool:${call.name}`; const action = "invoke"; @@ -128,11 +173,12 @@ export function createAuthzExtension( throw cause; } - const blocked = result.effect !== "allow"; + // An `ask` effect suspends the call rather than blocking it, so it is + // neither cleanly blocked nor allowed: the decision records + // `blocked: false` with no block reason. Only `deny`/null (fail-closed) + // are blocks. const blockReason = - result.effect === "deny" || - result.effect === "ask" || - result.effect === null + result.effect === "deny" || result.effect === null ? formatBlockReason(result.effect, resource, action) : undefined; @@ -144,13 +190,84 @@ export function createAuthzExtension( effect: result.effect, resolvedBy: result.resolvedBy, matchingGrants: result.matchingGrants, - blocked, + blocked: blockReason !== undefined, blockReason, error: undefined, }; safeOnDecision(opts.onDecision, decision); - return blockReason; + // A one-shot token only authorizes bypassing an `ask` gate. If the + // resolved effect is anything else, the grant changed underneath the + // token: drop it and let the normal path decide, rather than silently + // allowing a call the policy no longer parks. + if (approvedOnce.has(call.id) && result.effect !== "ask") { + approvedOnce.delete(call.id); + } + + if (blockReason !== undefined) { + return { type: "block", reason: blockReason }; + } + + if (result.effect === "ask") { + // A prior approval authorized this exact call to run once. Consume the + // token (delete-on-read) and allow it through instead of suspending, + // so a re-dispatched approved call does not re-park on its own gate. + if (approvedOnce.has(call.id)) { + approvedOnce.delete(call.id); + return { type: "allow" }; + } + + // Mint the correlationId once here so it is the single source of + // identity for both the gate and the persisted operation. The + // reactor persists the operation, so this id survives a restart. + const correlationId = crypto.randomUUID(); + const timeoutAt = + Date.now() + (opts.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS); + const gateId = `pending-${correlationId}`; + + // Build the approver-facing snapshot when tool definitions are wired. + // A wired extension must have a definition for every tool it can + // authorize, so a miss is a wiring defect rather than a fallback. An + // unwired extension produces no snapshot: such callers never register + // the suspension with the hub, so the downstream required-snapshot + // validator never sees them. + let approvalSnapshot: ApprovalSnapshot | undefined; + if (toolDefinitionsByName !== undefined) { + const def = toolDefinitionsByName.get(call.name); + if (def === undefined) { + throw new Error( + `Tool "${call.name}" was authorized with effect "ask" but has ` + + `no definition in the resolved tool set; the approval ` + + `snapshot cannot be built. This is a wiring defect: every ` + + `tool the authz extension can authorize must be present in ` + + `toolDefinitions.`, + ); + } + approvalSnapshot = { + name: call.name, + description: def.description, + inputSchema: def.inputSchema, + arguments: call.arguments, + }; + } + + const pendingOp: PendingOperation = { + correlationId, + kind: "approval", + registeredAt: Date.now(), + gateId, + timeoutAt, + suspendedCall: call, + ...(approvalSnapshot !== undefined ? { approvalSnapshot } : {}), + }; + return { + type: "suspend", + gate: { type: "approval", gateId, correlationId, timeoutAt }, + pendingOp, + }; + } + + return { type: "allow" }; }, }; } diff --git a/vendor/intx-inference/src/correlation.ts b/vendor/intx-inference/src/correlation.ts index 0d0aa7909..974a03d03 100644 --- a/vendor/intx-inference/src/correlation.ts +++ b/vendor/intx-inference/src/correlation.ts @@ -43,6 +43,13 @@ export function createCorrelationRegistry() { return operations.get(correlationId); } + function findByGateId(gateId: string): PendingOperation | undefined { + for (const op of operations.values()) { + if (op.gateId === gateId) return op; + } + return undefined; + } + function remove(correlationId: string): boolean { return operations.delete(correlationId); } @@ -55,7 +62,7 @@ export function createCorrelationRegistry() { return operations.size > 0; } - return { register, lookup, remove, all, hasAny }; + return { register, lookup, findByGateId, remove, all, hasAny }; } export type CorrelationRegistry = ReturnType; diff --git a/vendor/intx-inference/src/default-director.test.ts b/vendor/intx-inference/src/default-director.test.ts index a8b9b3c56..e41ac2e10 100644 --- a/vendor/intx-inference/src/default-director.test.ts +++ b/vendor/intx-inference/src/default-director.test.ts @@ -9,6 +9,7 @@ import type { ReactorAction, ReactorInboundEvent, ReactorState, + ToolCall, ToolResult, TokenUsage, } from "@intx/types/runtime"; @@ -307,3 +308,87 @@ describe("DefaultDirector — afterInferenceDone firing boundary", () => { expect(await fireHook(event)).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// resume.execute_tools seeds the outstanding-result counter +// +// The re-dispatch path never passes through inference.done, which is the only +// place the tool batch's count is seeded. Without a seed off resume.execute_ +// tools the count stays zero and the re-dispatched call's tool.done decrements +// to -1 and re-infers off a negative count by accident. These tests drive one +// director instance across the event sequence so the seed-then-decrement math +// is exercised against real state, not asserted per fresh instance. +// --------------------------------------------------------------------------- + +describe("DefaultDirector — resume.execute_tools counter seeding", () => { + function makeToolCall(id: string): ToolCall { + return { id, name: "charge_card", arguments: {} }; + } + + async function decideOn( + director: ReturnType, + event: ReactorInboundEvent, + ): Promise { + const result = await director.decide( + event, + makeState(), + createCapabilities(), + ); + return Array.isArray(result) ? result : [result]; + } + + test("seeds the count to the number of re-dispatched calls and re-infers only at zero", async () => { + const director = createDefaultDirector("test agent", []); + + // Two calls are about to run. The director must return the execute_tools + // action and seed its outstanding count to two. + const dispatch = await decideOn(director, { + type: "resume.execute_tools", + calls: [makeToolCall("a"), makeToolCall("b")], + }); + expect(dispatch).toEqual([ + { + type: "execute_tools", + calls: [makeToolCall("a"), makeToolCall("b")], + parallel: false, + addToHistory: true, + }, + ]); + + // First result: count 2 -> 1, no re-inference yet. An unseeded count would + // have gone 0 -> -1 and re-inferred here. + const afterFirst = await decideOn(director, { + type: "tool.done", + result: { callId: "a", content: "ok" }, + }); + expect(afterFirst).toEqual([]); + + // Second result: count 1 -> 0, re-infer exactly once. + const afterSecond = await decideOn(director, { + type: "tool.done", + result: { callId: "b", content: "ok" }, + }); + expect(afterSecond.map((action) => action.type)).toEqual([ + "checkpoint", + "infer", + ]); + }); + + test("a single re-dispatched call re-infers exactly once", async () => { + const director = createDefaultDirector("test agent", []); + + await decideOn(director, { + type: "resume.execute_tools", + calls: [makeToolCall("a")], + }); + + const afterResult = await decideOn(director, { + type: "tool.done", + result: { callId: "a", content: "ok" }, + }); + expect(afterResult.map((action) => action.type)).toEqual([ + "checkpoint", + "infer", + ]); + }); +}); diff --git a/vendor/intx-inference/src/default-director.ts b/vendor/intx-inference/src/default-director.ts index bd87654e3..e9c2dbd57 100644 --- a/vendor/intx-inference/src/default-director.ts +++ b/vendor/intx-inference/src/default-director.ts @@ -10,6 +10,8 @@ // inference.error → checkpoint + reply (error message to user) // abort → done // reactor.gate.cleared → checkpoint + infer (resume after gate) +// resume.execute_tools → execute_tools (re-run a parked approved call) +// resume.tool_result → checkpoint + infer (parked call denied/timed out) // // The inference.done branch additionally runs the optional afterInferenceDone // policy hook, whose continue/abort/halt decisions route independently of the @@ -19,15 +21,16 @@ // reply so the problem is visible, and the agent remains alive for retries. import { getLogger } from "@intx/log"; -import type { - ReactorDirector, - ReactorInboundEvent, - ReactorState, - ReactorCapabilities, - ReactorAction, - AssistantTurn, - ToolCall, - ToolDefinition, +import { + formatSafetyRatingText, + type ReactorDirector, + type ReactorInboundEvent, + type ReactorState, + type ReactorCapabilities, + type ReactorAction, + type AssistantTurn, + type ToolCall, + type ToolDefinition, } from "@intx/types/runtime"; const logger = getLogger(["interchange", "inference", "default-director"]); @@ -150,20 +153,21 @@ function extractToolCalls(turn: AssistantTurn): ToolCall[] { } function extractTextContent(turn: AssistantTurn): string { - // Both regular text and refusal blocks carry human-readable model - // output that the connector needs to surface — a refusal-only turn - // (OpenAI strict-mode policy decline) would otherwise route through - // the empty-response branch below and never reach the reply path, - // leaving the human waiting for an answer the model already - // declined to give. The structural "this was a refusal" signal is - // preserved at the persistence layer (event-collector emits a - // refusal turn-part); the reply path only needs the words. + // Text, refusal, and safety_rating blocks all carry human-readable + // output the connector needs to surface. A refusal-only or + // safety-only turn would otherwise route through the empty-response + // branch below and never reach the reply path, leaving the human + // waiting for an answer the model already declined or blocked. + // Structural part kinds are preserved at the persistence layer; + // the reply path only needs the words. const parts: string[] = []; for (const block of turn.content) { if (block.type === "text") { parts.push(block.text); } else if (block.type === "refusal") { parts.push(block.reason); + } else if (block.type === "safety_rating") { + parts.push(formatSafetyRatingText(block)); } } return parts.join("\n").trim(); @@ -297,6 +301,32 @@ export class DefaultDirector implements ReactorDirector { return [capabilities.checkpoint("inference-done"), capabilities.wait()]; } + case "resume.execute_tools": { + // A resumed approval re-runs its parked tool call. The reactor drives + // the execution; this director owns the outstanding-result count, so + // seed it to the number of calls about to run — exactly as the + // inference.done branch seeds it for a fresh tool batch. Without this + // seed the count stays zero and the re-dispatched call's tool.done + // would decrement to -1 and re-infer off a negative count by accident. + this.pendingToolResults = event.calls.length; + return capabilities.executeTools(event.calls, false, true); + } + + case "resume.tool_result": { + // A parked approval ended without running its tool (rejected or timed + // out). The reactor appends the synthetic error result that answers the + // parked call, then this re-infers once so the model sees the failure + // and continues. No tool ran, so pendingToolResults is untouched — the + // counter only gates batches of real executions. + return [ + capabilities.checkpoint("resume-tool-result"), + capabilities.infer({ + systemPrompt: this.systemPrompt, + tools: this.toolDefinitions, + }), + ]; + } + case "tool.done": { this.pendingToolResults--; if (this.pendingToolResults > 0) { diff --git a/vendor/intx-inference/src/errors.test.ts b/vendor/intx-inference/src/errors.test.ts index bb4fec366..7323a3f51 100644 --- a/vendor/intx-inference/src/errors.test.ts +++ b/vendor/intx-inference/src/errors.test.ts @@ -87,11 +87,6 @@ describe("classifyAbortError", () => { const err = classifyAbortError(); expect(err.category).toBe("aborted"); }); - - test("stores AbortSignal.reason on raw.origin when provided", () => { - const err = classifyAbortError("internal-recovery"); - expect(err.raw).toEqual({ origin: "internal-recovery" }); - }); }); describe("classifyStreamError", () => { diff --git a/vendor/intx-inference/src/errors.ts b/vendor/intx-inference/src/errors.ts index 034f7ec0d..cf489ef06 100644 --- a/vendor/intx-inference/src/errors.ts +++ b/vendor/intx-inference/src/errors.ts @@ -46,6 +46,8 @@ export function classifyNetworkError(cause: unknown): InferenceError { /** * `origin` mirrors AbortSignal.reason from the send path * (e.g. intercode `user-stop` / `internal-recovery` string literals). + * + * Locally patched — see vendor/intx-inference/PATCHES.md#errors-ts */ export type ClassifiedAbortRaw = { origin: unknown }; diff --git a/vendor/intx-inference/src/gates.ts b/vendor/intx-inference/src/gates.ts index d4b665956..4de576f1c 100644 --- a/vendor/intx-inference/src/gates.ts +++ b/vendor/intx-inference/src/gates.ts @@ -93,6 +93,21 @@ export function createGateManager() { return true; } + // Clear a gate without invoking its onCleared callback. The caller has + // already decided how the reactor resumes and does not want the standard + // cleared-event enqueue that onCleared drives. Used by the approval + // re-dispatch path, which resumes by re-running the parked tool call rather + // than by re-inferring off a gate-cleared event: firing onCleared there + // would enqueue a second, spurious continuation. + function clearSilently(gateId: string): boolean { + const gate = gates.get(gateId); + if (gate === undefined) return false; + clearTimeout(gate.timer); + gates.delete(gateId); + gate.resolve("resolved"); + return true; + } + function shutdown(): void { const entries = Array.from(gates.values()); gates.clear(); @@ -122,7 +137,15 @@ export function createGateManager() { return gates.has(gateId); } - return { register, clear, shutdown, findByCorrelationId, snapshot, has }; + return { + register, + clear, + clearSilently, + shutdown, + findByCorrelationId, + snapshot, + has, + }; } export type GateManager = ReturnType; diff --git a/vendor/intx-inference/src/harness.test.ts b/vendor/intx-inference/src/harness.test.ts index b9f16e86d..ff7ee0bb8 100644 --- a/vendor/intx-inference/src/harness.test.ts +++ b/vendor/intx-inference/src/harness.test.ts @@ -12,8 +12,8 @@ import { createDefaultDependencies, loadAdapterRegistry, } from "./providers"; -import { createAdapterRegistry, type AdapterFactory } from "./adapter"; -import { ProtocolMismatchError } from "./errors"; +import { createAdapterRegistry } from "./adapter"; +import type { AdapterFactory, AdapterRegistry } from "./adapter"; import type { ConversationTurn, InferenceEvent, @@ -28,6 +28,9 @@ const SOURCE: InferenceSource = { model: "claude-3-5-sonnet-20240620", }; +// Local copy: @intx/inference-testing depends on @intx/inference, so +// importing the shared userTurn helper here would create a package +// dependency cycle. function userTurn(text: string): ConversationTurn { return { role: "user", @@ -396,6 +399,7 @@ describe("runInference — providerOptions merge precedence", () => { }; }, parseResponse: () => [], + parseJSONResponse: () => [], }); const adapters = await loadAdapterRegistry( [{ provider: providerName, specifier: "x", export: "make" }], @@ -672,764 +676,190 @@ describe("runInference — source-identity stamping", () => { }); }); -describe("runInference — adapter-signalled stream termination", () => { - // A Responses-style protocol whose end-of-turn is a semantic event - // (`response.completed`), not `[DONE]` or a socket close. The adapter reports - // that event as terminal via `isStreamTerminal`; the harness must stop - // reading once it is processed rather than blocking on the next read. - const RESPONSES_SOURCE: InferenceSource = { - id: "test-responses:model", - provider: "test-responses", - baseURL: "https://example.test", - apiKey: "test", - model: "model", - }; - - const responsesAdapterFactory: AdapterFactory = (source) => ({ - buildRequest: () => ({ - url: "https://example.test/responses", - headers: {}, - body: "{}", - }), - parseResponse: (sseData) => { - let parsed: unknown; - try { - parsed = JSON.parse(sseData); - } catch (cause) { - throw new ProtocolMismatchError( - `test-responses: malformed JSON: ${cause instanceof Error ? cause.message : String(cause)}`, - sseData, - ); - } - const event = parsed as Record; - if (event["type"] === "response.output_text.delta") { - return [ - { - type: "inference.text.delta", - seq: 0, - data: { - token: String(event["delta"]), - partial: { text: "" }, - index: 0, - }, - }, - ]; - } - if (event["type"] === "response.completed") { - return [ - { - type: "inference.usage", - seq: 0, - data: { - usage: { - input: 3, - output: 1, - cacheRead: 0, - cacheWrite: 0, - thinking: 0, - }, - source, - }, - }, - ]; - } - return []; - }, - isStreamTerminal: (sseData) => { - try { - return ( - (JSON.parse(sseData) as Record)["type"] === - "response.completed" - ); - } catch { - return false; - } - }, - }); - - function sseStream(chunks: string[]): ReadableStream { - const encoder = new TextEncoder(); - let i = 0; - return new ReadableStream({ - pull(controller) { - const chunk = chunks[i]; - if (chunk === undefined) { - controller.close(); - return; - } - i += 1; - controller.enqueue(encoder.encode(chunk)); +describe("runInference — source quirks reach the adapter registry", () => { + // A registry that records the quirks argument every resolve receives and + // returns a do-nothing adapter, so the test observes exactly what the + // harness forwards without depending on any real provider. + function spyRegistry(): { + registry: AdapterRegistry; + quirksCalls: unknown[]; + } { + const quirksCalls: unknown[] = []; + const registry: AdapterRegistry = { + has: () => true, + resolve: (_source, quirks) => { + quirksCalls.push(quirks); + return { + buildRequest: () => ({ + url: "/v1/messages", + headers: {}, + body: "{}", + }), + parseResponse: () => [], + parseJSONResponse: () => [], + }; }, - }); + }; + return { registry, quirksCalls }; } - test("stops reading after the terminal event and does not consume later chunks", async () => { - // A poison chunk of malformed JSON sits AFTER `response.completed`. If the - // harness kept reading past the terminal event it would parse the poison - // and surface an `inference.error`; breaking on the terminal event means it - // is never read, so the turn finishes clean. This is the regression guard - // for the freeze: on the real Codex backend the post-completion chunk is an - // open connection rather than poison, but the read discipline is identical. - const deps: Dependencies = { + function depsWith(registry: AdapterRegistry): Dependencies { + return { fetch: () => Promise.resolve( - new Response( - sseStream([ - `data: {"type":"response.output_text.delta","delta":"hi"}\n\n`, - `data: {"type":"response.completed","response":{}}\n\n`, - `data: {not valid json\n\n`, - ]), - { - status: 200, - headers: { "content-type": "text/event-stream" }, - }, - ), + new Response("", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), ), scheduler: createDefaultScheduler(), - adapters: createAdapterRegistry({ - "test-responses": responsesAdapterFactory, - }), + adapters: registry, }; + } + test("forwards the source's quirks bag to resolve", async () => { + const { registry, quirksCalls } = spyRegistry(); let seq = 0; - const events = await collect( + await collect( runInference({ - turns: [userTurn("hello")], - source: RESPONSES_SOURCE, + turns: [userTurn("hi")], + source: { ...SOURCE, quirks: { forceAssistantReasoningContent: true } }, nextSeq: () => ++seq, - deps, + deps: depsWith(registry), }), ); - - const errorEvent = events.find((e) => e.type === "inference.error"); - expect(errorEvent).toBeUndefined(); - - const doneEvent = events.find((e) => e.type === "inference.done"); - if (doneEvent === undefined) throw new Error("missing inference.done"); - - const textBlock = doneEvent.data.turn.content.find( - (b) => b.type === "text", - ); - if (textBlock === undefined || textBlock.type !== "text") { - throw new Error("expected a text block in the finalized turn"); - } - expect(textBlock.text).toBe("hi"); - }); -}); - -describe("runInference — inactivity watchdog counts semantic events, not bytes", () => { - // Regression guard for the hang where a provider stream trickled keep-alive - // envelopes (parsing to zero semantic events) forever. The inactivity timer - // must measure silence since the last event the adapter parsed out, not since - // the last byte on the wire, or a stream that never emits a terminal event - // pins the caller indefinitely. - const KEEPALIVE_SOURCE: InferenceSource = { - id: "test-keepalive:model", - provider: "test-keepalive", - baseURL: "https://example.test", - apiKey: "test", - model: "model", - }; - - // Every chunk is a lifecycle envelope carrying no content; the adapter parses - // each to zero events and never reports a terminal. - const keepAliveAdapterFactory: AdapterFactory = () => ({ - buildRequest: () => ({ - url: "https://example.test/keepalive", - headers: {}, - body: "{}", - }), - parseResponse: () => [], - isStreamTerminal: () => false, + expect(quirksCalls).toEqual([{ forceAssistantReasoningContent: true }]); }); - type ManualTimer = { fireAt: number; cb: () => void; cancelled: boolean }; - - function createManualScheduler() { - let nowMs = 0; - const timers = new Set(); - return { - now: () => nowMs, - setTimeout(cb: () => void, delayMs: number) { - const timer: ManualTimer = { - fireAt: nowMs + delayMs, - cb, - cancelled: false, - }; - timers.add(timer); - return () => { - timer.cancelled = true; - timers.delete(timer); - }; - }, - advance(ms: number) { - nowMs += ms; - const due = [...timers] - .filter((t) => !t.cancelled && t.fireAt <= nowMs) - .sort((a, b) => a.fireAt - b.fireAt); - for (const t of due) { - if (t.cancelled) continue; - timers.delete(t); - t.cb(); - } - }, - }; - } - - test("keep-alive trickle without a terminal event trips the inactivity timeout", async () => { - const inactivityTimeoutMs = 1_000; - // Half the inactivity window per chunk: no single gap between keep-alives - // exceeds the deadline, so a byte-counting watchdog would be reset on every - // read and never fire. A semantic-event watchdog fires within two chunks. - const stepMs = 600; - const maxChunks = 40; - const scheduler = createManualScheduler(); - const encoder = new TextEncoder(); - let produced = 0; - - const deps: Dependencies = { - fetch: (_url, init) => { - const signal = init?.signal; - const body = new ReadableStream({ - pull(controller) { - // Virtual wall-clock advances as the harness pulls the next byte. - scheduler.advance(stepMs); - if (signal?.aborted === true) { - controller.error(new DOMException("aborted", "AbortError")); - return; - } - if (produced >= maxChunks) { - controller.close(); - return; - } - produced += 1; - controller.enqueue(encoder.encode(`data: {"type":"keep-alive"}\n\n`)); - }, - }); - return Promise.resolve( - new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - ); - }, - scheduler, - adapters: createAdapterRegistry({ - "test-keepalive": keepAliveAdapterFactory, - }), - }; - + test("resolves with undefined quirks when the source has none", async () => { + const { registry, quirksCalls } = spyRegistry(); let seq = 0; - const events = await collect( + await collect( runInference({ - turns: [userTurn("hello")], - source: KEEPALIVE_SOURCE, - // Surface the first terminal error rather than exercising the retry - // ladder — this test pins the watchdog, not the retry policy. - inferenceOptions: { - inactivityTimeoutMs, - retryPolicy: () => ({ kind: "abort" }), - }, + turns: [userTurn("hi")], + source: SOURCE, nextSeq: () => ++seq, - deps, + deps: depsWith(registry), }), ); - - const doneEvent = events.find((e) => e.type === "inference.done"); - expect(doneEvent).toBeUndefined(); - - const errorEvent = events.find((e) => e.type === "inference.error"); - if (errorEvent === undefined || errorEvent.type !== "inference.error") { - throw new Error("expected a terminal inference.error from the watchdog"); - } - expect(errorEvent.data.error.category).toBe("timeout"); - // The watchdog fires while the stream is still trickling, well before the - // 40-chunk stream would end on its own. - expect(produced).toBeLessThan(maxChunks); + expect(quirksCalls).toEqual([undefined]); }); }); -// --------------------------------------------------------------------------- -// runInference — incremental delivery and memory-linear buffering -// -// The wrapper streams an attempt's committed content to the caller as it -// arrives rather than buffering the whole attempt and flushing at -// termination. Two consequences are pinned here: -// -// * Memory: the wrapper's retained buffer holds only pre-commit -// metadata, so the number of undelivered events in flight stays a -// small constant regardless of output length. Under the old -// buffer-everything model that gap scaled with the token count. -// -// * Commitment: once a content delta has been delivered it cannot be -// un-emitted, so a later failure is surfaced rather than retried; a -// failure that lands before any content is still retried cleanly. -// --------------------------------------------------------------------------- - -describe("runInference — incremental delivery and memory-linear buffering", () => { - const STREAM_SOURCE: InferenceSource = { - id: "test-stream:model", - provider: "test-stream", +describe("runInference — non-streaming JSON responses", () => { + const JSON_SOURCE: InferenceSource = { + id: "test-json:model-x", + provider: "test-json", baseURL: "https://example.test", apiKey: "test", - model: "model", + model: "model-x", }; - // A minimal adapter that turns compact JSON lines into content deltas. - // `partial` is stubbed on the raw events because the harness owns the - // running partial state and re-snapshots it on every emit. - const streamAdapterFactory: AdapterFactory = () => ({ - buildRequest: () => ({ - url: "https://example.test/stream", - headers: {}, - body: "{}", - }), - parseResponse: (sseData) => { - const msg = JSON.parse(sseData) as Record; - switch (msg["kind"]) { - case "text": - return [ - { - type: "inference.text.delta", - seq: 0, - data: { token: String(msg["token"]), partial: { text: "" }, index: 0 }, - }, - ]; - case "thinking": - return [ - { - type: "inference.thinking.delta", - seq: 0, - data: { token: String(msg["token"]), partial: { text: "" }, index: 0 }, - }, - ]; - case "tool_start": - return [ - { - type: "inference.tool_call.start", - seq: 0, - data: { - callId: String(msg["id"]), - name: String(msg["name"]), - partial: { text: "" }, - index: 0, - }, - }, - ]; - case "tool_arg": - return [ - { - type: "inference.tool_call.delta", - seq: 0, - data: { - callId: String(msg["id"]), - argumentFragment: String(msg["frag"]), - partial: { text: "" }, - }, - }, - ]; - default: - return []; - } - }, - }); - - function makeStream(opts: { - chunks: string[]; - counters?: { produced: number }; - beforeProduce?: (index: number) => Promise | void; - }): ReadableStream { - const encoder = new TextEncoder(); - let i = 0; - return new ReadableStream({ - async pull(controller) { - if (i >= opts.chunks.length) { - controller.close(); - return; - } - const index = i; - if (opts.beforeProduce !== undefined) await opts.beforeProduce(index); - const chunk = opts.chunks[index]; - if (chunk === undefined) { - controller.close(); - return; - } - controller.enqueue(encoder.encode(chunk)); - i += 1; - if (opts.counters !== undefined) opts.counters.produced = i; - }, - }); - } - - function streamDeps(body: ReadableStream): Dependencies { + function jsonDeps( + body: string, + contentType: string, + factory: AdapterFactory, + ): Dependencies { return { fetch: () => Promise.resolve( new Response(body, { status: 200, - headers: { "content-type": "text/event-stream" }, + headers: { "content-type": contentType }, }), ), scheduler: createDefaultScheduler(), - adapters: createAdapterRegistry({ "test-stream": streamAdapterFactory }), + adapters: createAdapterRegistry({ "test-json": factory }), }; } - function withTimeout(p: Promise, ms: number, msg: string): Promise { - return Promise.race([ - p, - new Promise((_, reject) => - setTimeout(() => reject(new Error(msg)), ms), - ), - ]); - } - - function textChunks(n: number, token = "x"): string[] { - return Array.from( - { length: n }, - () => `data: ${JSON.stringify({ kind: "text", token })}\n\n`, - ); - } - - // Drive a long stream, tracking the largest gap between events produced - // at the wire and events delivered to the caller. That gap is the depth - // of the wrapper's retained buffer — the quantity this guard keeps linear. - // The `done` event is kept for correctness assertions; per-delta events - // are deliberately not retained so the test itself stays linear. - async function runLongStream(opts: { - chunks: string[]; - counters: { produced: number }; - isDelivered: (event: InferenceEvent) => boolean; - }): Promise<{ - done: Extract | undefined; - delivered: number; - maxGap: number; - }> { - const deps = streamDeps(makeStream({ chunks: opts.chunks, counters: opts.counters })); - let seq = 0; - let delivered = 0; - let maxGap = 0; - let done: Extract | undefined; - for await (const ev of runInference({ - turns: [userTurn("hi")], - source: STREAM_SOURCE, - nextSeq: () => ++seq, - deps, - })) { - if (ev.type === "inference.done") done = ev; - if (opts.isDelivered(ev)) { - delivered += 1; - const gap = opts.counters.produced - delivered; - if (gap > maxGap) maxGap = gap; - } - } - return { done, delivered, maxGap }; - } - - const GAP_BOUND = 20; - - test("retained buffer depth stays bounded as output length grows", async () => { - const small = await runLongStream({ - chunks: textChunks(100), - counters: { produced: 0 }, - isDelivered: (e) => e.type === "inference.text.delta", - }); - const large = await runLongStream({ - chunks: textChunks(2000), - counters: { produced: 0 }, - isDelivered: (e) => e.type === "inference.text.delta", - }); - - expect(small.delivered).toBe(100); - expect(large.delivered).toBe(2000); - // Old buffer-everything behavior held every delta event before - // delivering the first, so this gap scaled with N (~100 then ~2000). - // Streaming committed deltas keeps it a small constant either way — - // the signature of memory linear (not quadratic) in output length. - expect(small.maxGap).toBeLessThan(GAP_BOUND); - expect(large.maxGap).toBeLessThan(GAP_BOUND); - }); - - test("long text stream assembles correctly with bounded buffering", async () => { - const n = 1000; - const { done, delivered, maxGap } = await runLongStream({ - chunks: textChunks(n, "x"), - counters: { produced: 0 }, - isDelivered: (e) => e.type === "inference.text.delta", - }); - expect(delivered).toBe(n); - expect(maxGap).toBeLessThan(GAP_BOUND); - if (done === undefined) throw new Error("missing inference.done"); - const block = done.data.turn.content.find((b) => b.type === "text"); - if (block === undefined || block.type !== "text") { - throw new Error("expected a text block"); - } - expect(block.text).toBe("x".repeat(n)); - }); - - test("long reasoning stream assembles correctly with bounded buffering", async () => { - const n = 1000; - const chunks = Array.from( - { length: n }, - () => `data: ${JSON.stringify({ kind: "thinking", token: "r" })}\n\n`, - ); - const { done, delivered, maxGap } = await runLongStream({ - chunks, - counters: { produced: 0 }, - isDelivered: (e) => e.type === "inference.thinking.delta", - }); - expect(delivered).toBe(n); - expect(maxGap).toBeLessThan(GAP_BOUND); - if (done === undefined) throw new Error("missing inference.done"); - const block = done.data.turn.content.find((b) => b.type === "thinking"); - if (block === undefined || block.type !== "thinking") { - throw new Error("expected a thinking block"); - } - expect(block.thinking).toBe("r".repeat(n)); - }); - - test("long tool-argument stream assembles correctly with bounded buffering", async () => { - const bigValue = "a".repeat(4000); - const inner = JSON.stringify({ data: bigValue }); - const argChunks: string[] = []; - for (let p = 0; p < inner.length; p += 8) { - const frag = inner.slice(p, p + 8); - argChunks.push( - `data: ${JSON.stringify({ kind: "tool_arg", id: "call_1", frag })}\n\n`, - ); - } - const chunks = [ - `data: ${JSON.stringify({ kind: "tool_start", id: "call_1", name: "do_thing" })}\n\n`, - ...argChunks, - ]; - const { done, delivered, maxGap } = await runLongStream({ - chunks, - counters: { produced: 0 }, - isDelivered: (e) => e.type === "inference.tool_call.delta", - }); - expect(delivered).toBe(argChunks.length); - expect(maxGap).toBeLessThan(GAP_BOUND); - if (done === undefined) throw new Error("missing inference.done"); - const block = done.data.turn.content.find((b) => b.type === "tool_call"); - if (block === undefined || block.type !== "tool_call") { - throw new Error("expected a tool_call block"); - } - expect(block.arguments["data"]).toBe(bigValue); - }); - - test("delivers committed deltas incrementally instead of buffering to the end", async () => { - const n = 8; - let delivered = 0; - let notify: (() => void) | null = null; - const waitUntilDelivered = (count: number): Promise => { - if (delivered >= count) return Promise.resolve(); - return new Promise((resolve) => { - const check = (): void => { - if (delivered >= count) { - notify = null; - resolve(); - } else { - notify = check; - } - }; - notify = check; - }); - }; - // Gate wire chunk `index` behind delivery of `index` deltas to the - // caller. A wrapper that buffered the whole attempt before delivering - // anything would never let `delivered` advance past 0, deadlocking the - // producer — so completing at all proves deltas flow mid-stream. - const deps = streamDeps( - makeStream({ - chunks: textChunks(n, "z"), - beforeProduce: (index) => waitUntilDelivered(index), - }), - ); - let seq = 0; - const received = await withTimeout( - (async () => { - const tokens: string[] = []; - for await (const ev of runInference({ - turns: [userTurn("hi")], - source: STREAM_SOURCE, - nextSeq: () => ++seq, - deps, - })) { - if (ev.type === "inference.text.delta") { - delivered += 1; - notify?.(); - tokens.push(ev.data.token); - } - } - return tokens; - })(), - 2000, - "incremental delivery deadlocked — the wrapper buffered instead of streaming", - ); - expect(received.length).toBe(n); + // Re-expresses a whole non-streaming body as the harness's delta/marker + // protocol: the assistant text as a single indexed text.delta plus a usage + // event, exactly as an SSE parser would emit them incrementally. + const decodingFactory: AdapterFactory = (source) => ({ + buildRequest: (_messages, model) => ({ + url: "https://example.test/v1/json", + headers: {}, + body: JSON.stringify({ model }), + }), + parseResponse: () => [], + parseJSONResponse: (raw) => { + const parsed: unknown = JSON.parse(raw); + const token = typeof parsed === "string" ? parsed : ""; + return [ + { + type: "inference.text.delta", + seq: 0, + data: { token, partial: { text: "" }, index: 0 }, + }, + { + type: "inference.usage", + seq: 0, + data: { + usage: { + input: 7, + output: 3, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, + source, + }, + }, + ]; + }, }); - test("retries an uncommitted failure and delivers exactly one clean stream", async () => { - let calls = 0; - const deps: Dependencies = { - fetch: () => { - calls += 1; - if (calls === 1) { - return Promise.resolve( - new Response("upstream unavailable", { status: 503 }), - ); - } - return Promise.resolve( - new Response(makeStream({ chunks: textChunks(1, "hello") }), { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - ); - }, - scheduler: createDefaultScheduler(), - adapters: createAdapterRegistry({ "test-stream": streamAdapterFactory }), - }; + test("decodes a JSON body through the adapter's parseJSONResponse", async () => { let seq = 0; const events = await collect( runInference({ turns: [userTurn("hi")], - source: STREAM_SOURCE, - inferenceOptions: { - retryPolicy: ({ attempt }) => - attempt < 2 ? { kind: "retry", delayMs: 0 } : { kind: "abort" }, - }, + source: JSON_SOURCE, nextSeq: () => ++seq, - deps, + deps: jsonDeps( + JSON.stringify("hello from json"), + "application/json", + decodingFactory, + ), }), ); - expect(calls).toBe(2); - // The discarded attempt's inference.start does not leak: the caller - // sees exactly one, no orphaned error, one retry marker. - expect(events.filter((e) => e.type === "inference.start")).toHaveLength(1); - expect(events.filter((e) => e.type === "inference.retry")).toHaveLength(1); - expect(events.some((e) => e.type === "inference.error")).toBe(false); - const done = events.find((e) => e.type === "inference.done"); - if (done === undefined) throw new Error("missing inference.done"); - const block = done.data.turn.content.find((b) => b.type === "text"); - expect(block !== undefined && block.type === "text" ? block.text : "").toBe( - "hello", + + const textDelta = events.find( + (e): e is Extract => + e.type === "inference.text.delta", + ); + if (textDelta === undefined) throw new Error("missing text delta"); + expect(textDelta.data.token).toBe("hello from json"); + + const usage = events.find( + (e): e is Extract => + e.type === "inference.usage", ); + if (usage === undefined) throw new Error("missing usage"); + expect(usage.data.usage.input).toBe(7); + expect(usage.data.usage.output).toBe(3); + + expect(events.some((e) => e.type === "inference.done")).toBe(true); }); - test("suppresses retry once output is committed and surfaces the error", async () => { - let calls = 0; - const deps: Dependencies = { - fetch: () => { - calls += 1; - const encoder = new TextEncoder(); - let i = 0; - const body = new ReadableStream({ - pull(controller) { - if (i < 2) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ kind: "text", token: `p${String(i)}` })}\n\n`, - ), - ); - i += 1; - return; - } - controller.error(new Error("connection reset mid-stream")); - }, - }); - return Promise.resolve( - new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - ); - }, - scheduler: createDefaultScheduler(), - adapters: createAdapterRegistry({ "test-stream": streamAdapterFactory }), - }; + test("surfaces a protocol mismatch on an unsupported content-type", async () => { let seq = 0; const events = await collect( runInference({ turns: [userTurn("hi")], - source: STREAM_SOURCE, - // A policy that would always retry — proving that commitment, not - // the policy, is what suppresses the retry here. - inferenceOptions: { retryPolicy: () => ({ kind: "retry", delayMs: 0 }) }, + source: JSON_SOURCE, nextSeq: () => ++seq, - deps, + deps: jsonDeps("plain body", "text/plain", decodingFactory), }), ); - // No second attempt: the committed prefix cannot be un-emitted. - expect(calls).toBe(1); - const tokens = events - .filter((e) => e.type === "inference.text.delta") - .map((e) => (e.type === "inference.text.delta" ? e.data.token : "")); - expect(tokens).toEqual(["p0", "p1"]); - expect(events.some((e) => e.type === "inference.retry")).toBe(false); - const errorIndex = events.findIndex((e) => e.type === "inference.error"); - expect(errorIndex).toBeGreaterThan(-1); - const lastDeltaIndex = events - .map((e) => e.type) - .lastIndexOf("inference.text.delta"); - // The committed deltas precede the surfaced error in one clean stream. - expect(lastDeltaIndex).toBeLessThan(errorIndex); - }); - test("cancellation interrupts promptly after output has started", async () => { - const controller = new AbortController(); - let delivered = 0; - let notify: (() => void) | null = null; - const waitUntilDelivered = (count: number): Promise => { - if (delivered >= count) return Promise.resolve(); - return new Promise((resolve) => { - const check = (): void => { - if (delivered >= count) { - notify = null; - resolve(); - } else { - notify = check; - } - }; - notify = check; - }); - }; - const n = 1000; - const deps = streamDeps( - makeStream({ - chunks: textChunks(n, "y"), - beforeProduce: (index) => waitUntilDelivered(index), - }), + const errorEvent = events.find( + (e): e is Extract => + e.type === "inference.error", ); - let seq = 0; - const received: InferenceEvent[] = []; - await withTimeout( - (async () => { - for await (const ev of runInference({ - turns: [userTurn("hi")], - source: STREAM_SOURCE, - signal: controller.signal, - nextSeq: () => ++seq, - deps, - })) { - received.push(ev); - if (ev.type === "inference.text.delta") { - delivered += 1; - notify?.(); - if (delivered === 1) controller.abort(); - } - } - })(), - 2000, - "cancellation did not interrupt the stream", - ); - const deltaCount = received.filter( - (e) => e.type === "inference.text.delta", - ).length; - // The abort cut the stream far short of its full length. - expect(deltaCount).toBeGreaterThanOrEqual(1); - expect(deltaCount).toBeLessThan(n); - const error = received.find((e) => e.type === "inference.error"); - if (error === undefined) throw new Error("missing aborted inference.error"); - expect(error.data.error.category).toBe("aborted"); + if (errorEvent === undefined) throw new Error("missing inference.error"); + expect(errorEvent.data.error.category).toBe("protocol_mismatch"); + expect(errorEvent.data.error.message).toContain("text/plain"); }); }); diff --git a/vendor/intx-inference/src/harness.ts b/vendor/intx-inference/src/harness.ts index 3d3971260..b8d228705 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -29,6 +29,7 @@ import type { LastCycleSource, PartialMessage, RetryDecision, + SafetyRatingBlock, TokenUsage, AssistantTurn, ContentBlock, @@ -36,6 +37,11 @@ import type { import { getLogger } from "@intx/log"; +import { + detectResponseKind, + type ResponseKind, +} from "@intx/types/content-type"; + import type { AdapterRegistry } from "./adapter"; import { parseSSE } from "./sse"; import { injectCredentials } from "./auth"; @@ -45,6 +51,7 @@ import { classifyAbortError, classifyStreamError, classifyTimeoutError, + classifyProtocolMismatch, ProtocolMismatchError, } from "./errors"; import { createDefaultRetryPolicy } from "./retry-policy"; @@ -115,6 +122,8 @@ export type Dependencies = { * forwards `deps` into reactor assembly verbatim while exposing no env * field for transforms; riding `deps` reaches the vendored assembly * without modifying the published package. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts */ readonly contextTransforms?: ContextTransform[]; readonly [HarnessId]?: symbol; @@ -244,13 +253,17 @@ async function* runSingleAttempt( // `completedToolCalls` and is resolved into the final block at // assembly time via the marker's `callId`. type BlockState = - | { kind: "text"; text: string } + | { kind: "text"; text: string; signature?: string } | { kind: "thinking"; text: string; signature?: string } | { kind: "redacted_thinking"; data: string } | { kind: "refusal"; reason: string } - | { kind: "tool_use"; callId: string } - | { kind: "image"; image: ImageBlock } - | { kind: "code_execution_request"; request: CodeExecutionRequestBlock } + | { kind: "tool_use"; callId: string; signature?: string } + | { kind: "image"; image: ImageBlock; signature?: string } + | { + kind: "code_execution_request"; + request: CodeExecutionRequestBlock; + signature?: string; + } | { kind: "code_execution_result"; result: CodeExecutionResultBlock }; const blockMap = new Map(); // Citations streamed from the provider. Indexed citations attribute @@ -261,6 +274,9 @@ async function* runSingleAttempt( // different keys. const citationsByIndex = new Map(); const unindexedCitations: CitationBlock[] = []; + // Prompt-level safety signals (no candidate index on the first + // capture). Appended to the finalized turn after indexed blocks. + const unindexedSafetyRatings: SafetyRatingBlock[] = []; let usageSeen: TokenUsage | null = null; // Tool call state: keyed by callId (or index for OpenAI). @@ -277,14 +293,14 @@ async function* runSingleAttempt( yield { type: "inference.error", seq: nextSeq(), - data: { error: classifyAbortError(signal.reason), partial: snapshotPartial(partial) }, + data: { error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial) }, }; return; } let adapter; try { - adapter = deps.adapters.resolve(lastCycleSource); + adapter = deps.adapters.resolve(lastCycleSource, source.quirks); } catch (cause) { yield { type: "inference.error", @@ -395,7 +411,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(signal.reason), + error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial), }, }; @@ -465,15 +481,81 @@ async function* runSingleAttempt( }; return; } + // Captured as a const so the non-null narrowing from the guard above + // carries into the SSE branch of the event-source generator below (a + // bare `response.body` re-widens to nullable across the closure). + const responseBody = response.body; + + let responseKind: ResponseKind; + try { + responseKind = detectResponseKind(response.headers); + } catch (cause) { + // A 2xx whose Content-Type is neither SSE nor JSON is a protocol + // violation, not a transient failure — surface it loudly rather than + // pushing unknown bytes through the SSE parser to yield an empty turn. + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyProtocolMismatch( + cause instanceof Error ? cause.message : String(cause), + ), + partial: snapshotPartial(partial), + }, + }; + return; + } + + // Arm the inactivity timer now that the SSE stream is open. Every + // event we yield below resets it; sustained silence past + // `inactivityTimeoutMs` aborts the controller and the loop's catch + // surfaces the timeout error. A non-streaming JSON body has no + // inter-event silence to detect, so the timer stays disarmed there and + // the total-timeout controller alone bounds the buffered read. + if (responseKind === "sse") { + armInactivity(); + } - // Arm the inactivity timer now that the SSE stream is open. Only - // semantic events parsed from each chunk re-arm it (see the loop - // below); sustained silence past `inactivityTimeoutMs` aborts the - // controller and the loop's catch surfaces the timeout error. - armInactivity(); + // The event source: one branch per response kind, both feeding batches + // of raw adapter events into the shared accumulator below. SSE yields + // one batch per wire chunk; JSON buffers the whole body and yields a + // single batch. + const rawEventBatches = async function* (): AsyncGenerator< + InferenceEvent[] + > { + if (responseKind === "json") { + const body = await awaitWithSignal(response.text(), fetchSignal); + yield adapter.parseJSONResponse(body); + return; + } + for await (const sseData of parseSSE(responseBody)) { + const rawEvents = adapter.parseResponse(sseData); + // Reset the inactivity watchdog only on semantic progress — events the + // adapter actually parsed out of this chunk (content, thinking, tool + // calls, usage). Provider keep-alives and lifecycle envelopes parse to + // zero events; letting raw bytes re-arm the timer means a stream that + // trickles keep-alives forever without a terminal event never trips the + // watchdog and pins the caller indefinitely. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + if (rawEvents.length > 0) { + armInactivity(); + } + yield rawEvents; + // Protocols whose end-of-turn is a semantic event (OpenAI Responses) + // rather than `[DONE]` or a socket close would otherwise block on the + // next read forever. Stop once the terminal event's own events (e.g. + // its usage) have been processed above. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + if (adapter.isStreamTerminal?.(sseData)) { + return; + } + } + }; try { - for await (const sseData of parseSSE(response.body)) { + for await (const rawEvents of rawEventBatches()) { if (timeoutReason !== null) { // The timeout aborted the stream; bubble up the right error // shape rather than letting the abort masquerade as a @@ -497,25 +579,13 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(signal.reason), + error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial), }, }; return; } - const rawEvents = adapter.parseResponse(sseData); - - // Reset the inactivity watchdog only on semantic progress — events the - // adapter actually parsed out of this chunk (content, thinking, tool - // calls, usage). Provider keep-alives and lifecycle envelopes parse to - // zero events; letting raw bytes re-arm the timer means a stream that - // trickles keep-alives forever without a terminal event never trips the - // watchdog and pins the caller indefinitely. - if (rawEvents.length > 0) { - armInactivity(); - } - for (const raw of rawEvents) { switch (raw.type) { case "inference.text.delta": { @@ -542,6 +612,7 @@ async function* runSingleAttempt( data: { token: raw.data.token, partial: snapshotPartial(partial), + index: idx, }, }; break; @@ -606,31 +677,42 @@ async function* runSingleAttempt( data: { token: raw.data.token, partial: snapshotPartial(partial), + index: idx, }, }; break; } - case "inference.thinking.signature": { - const idx = requireIndex(raw, "thinking.signature"); + case "inference.block.signature": { + const idx = requireIndex(raw, "block.signature"); const existing = blockMap.get(idx); if (existing === undefined) { throw new ProtocolMismatchError( - `harness: thinking.signature at index ${String(idx)} has no preceding thinking block at that index`, + `harness: block.signature at index ${String(idx)} has no preceding block at that index`, raw, ); } - if (existing.kind !== "thinking") { + // A signature authenticates the block whose part it rides on. + // The signable kinds are the ones whose ContentBlock carries a + // `signature` field; the others (redacted_thinking, refusal, + // code_execution_result) have no place to hold one. + if ( + existing.kind !== "thinking" && + existing.kind !== "text" && + existing.kind !== "tool_use" && + existing.kind !== "image" && + existing.kind !== "code_execution_request" + ) { throw new ProtocolMismatchError( - `harness: thinking.signature at index ${String(idx)} targets an existing ${existing.kind} block, not a thinking block`, + `harness: block.signature at index ${String(idx)} targets an existing ${existing.kind} block, which does not carry a signature`, raw, ); } existing.signature = raw.data.signature; yield { - type: "inference.thinking.signature", + type: "inference.block.signature", seq: nextSeq(), - data: { signature: raw.data.signature }, + data: { signature: raw.data.signature, index: idx }, }; break; } @@ -659,6 +741,17 @@ async function* runSingleAttempt( break; } + case "inference.safety_rating": { + const safetyRating = raw.data.safetyRating; + unindexedSafetyRatings.push(safetyRating); + yield { + type: "inference.safety_rating", + seq: nextSeq(), + data: { safetyRating }, + }; + break; + } + case "inference.thinking.redacted": { const idx = requireIndex(raw, "thinking.redacted"); const existing = blockMap.get(idx); @@ -727,7 +820,12 @@ async function* runSingleAttempt( yield { type: "inference.tool_call.start", seq: nextSeq(), - data: { callId, name, partial: snapshotPartial(partial) }, + data: { + callId, + name, + partial: snapshotPartial(partial), + index: toolIdx, + }, }; break; } @@ -936,14 +1034,6 @@ async function* runSingleAttempt( break; } } - - // Protocols whose end-of-turn is a semantic event (OpenAI Responses) - // rather than `[DONE]` or a socket close would otherwise block on the - // next read forever. Stop once the terminal event's own events (e.g. - // its usage) have been processed above. - if (adapter.isStreamTerminal?.(sseData)) { - break; - } } } catch (cause) { if (timeoutReason !== null) { @@ -964,7 +1054,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(signal.reason), + error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial), }, }; @@ -1064,9 +1154,22 @@ async function* runSingleAttempt( }; for (const [idx, entry] of blockMap.entries()) { if (entry.kind === "text") { - if (entry.text.length > 0) { - emit({ type: "text", text: entry.text }, idx); + // Emit even with empty text if a signature was captured, so a + // signature riding on an otherwise-empty text carrier still + // round-trips (mirrors the thinking-block rule below). + if (entry.text.length === 0 && entry.signature === undefined) { + continue; } + emit( + { + type: "text", + text: entry.text, + ...(entry.signature !== undefined + ? { signature: entry.signature } + : {}), + }, + idx, + ); continue; } if (entry.kind === "thinking") { @@ -1122,7 +1225,18 @@ async function* runSingleAttempt( entry, ); } - emit(finalized, idx); + if (finalized.type !== "tool_call") { + throw new ProtocolMismatchError( + `harness: tool_use marker at callId ${entry.callId} resolved to a ${finalized.type} block, not a tool_call`, + entry, + ); + } + emit( + entry.signature !== undefined + ? { ...finalized, signature: entry.signature } + : finalized, + idx, + ); continue; } if (entry.kind === "image") { @@ -1132,7 +1246,12 @@ async function* runSingleAttempt( // atomic, not streamed), so the final-walk emits it // verbatim. Citation interleave applies the same way as // any other block kind. - emit(entry.image, idx); + emit( + entry.signature !== undefined + ? { ...entry.image, signature: entry.signature } + : entry.image, + idx, + ); continue; } if (entry.kind === "code_execution_request") { @@ -1142,7 +1261,12 @@ async function* runSingleAttempt( // current wire delivers all of it atomically on `start`; // streaming providers would extend `request.code` via the // delta handler before this walk runs. - emit(entry.request, idx); + emit( + entry.signature !== undefined + ? { ...entry.request, signature: entry.signature } + : entry.request, + idx, + ); continue; } if (entry.kind === "code_execution_result") { @@ -1168,6 +1292,7 @@ async function* runSingleAttempt( ); } contentBlocks.push(...unindexedCitations); + contentBlocks.push(...unindexedSafetyRatings); const finalTurn: AssistantTurn = { role: "assistant", @@ -1236,6 +1361,8 @@ async function* runSingleAttempt( * `inference.error` from an attempt the policy chose to retry (only * uncommitted attempts are ever retried). * + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts + * * Caller-visible seqs stay contiguous across retries. Each attempt * runs against a private seq allocator; the wrapper re-stamps every * event with a seq from the caller's `nextSeq` as it is emitted, so a @@ -1484,6 +1611,8 @@ export async function* runInference( * response, so they are buffered rather than committing. `inference.done`, * `inference.error`, and `inference.retry` are terminal or wrapper-owned * and are handled by `runInference` before this predicate is consulted. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts */ function isCommitting(event: InferenceEvent): boolean { switch (event.type) { diff --git a/vendor/intx-inference/src/index.ts b/vendor/intx-inference/src/index.ts index 71a272efe..c139d39ff 100644 --- a/vendor/intx-inference/src/index.ts +++ b/vendor/intx-inference/src/index.ts @@ -45,7 +45,7 @@ export type { UploadedGoogleGenAIFile, } from "./providers/google-genai-files"; -export { assertWellFormedToolSequence, createInboundTurn } from "./turns"; +export { createInboundTurn, assertWellFormedToolSequence } from "./turns"; export { createReactor } from "./reactor"; export type { ExtendedInferenceOptions, diff --git a/vendor/intx-inference/src/manifest.test.ts b/vendor/intx-inference/src/manifest.test.ts index 562a2d82d..5b67a1966 100644 --- a/vendor/intx-inference/src/manifest.test.ts +++ b/vendor/intx-inference/src/manifest.test.ts @@ -10,6 +10,7 @@ function createStubAdapter(): ProviderAdapter { return { buildRequest: () => ({ url: "", headers: {}, body: "" }), parseResponse: () => [], + parseJSONResponse: () => [], }; } diff --git a/vendor/intx-inference/src/providers/anthropic.test.ts b/vendor/intx-inference/src/providers/anthropic.test.ts index bb15e8181..42c230528 100644 --- a/vendor/intx-inference/src/providers/anthropic.test.ts +++ b/vendor/intx-inference/src/providers/anthropic.test.ts @@ -2,12 +2,21 @@ import { describe, expect, test } from "bun:test"; import { type } from "arktype"; import type { + AssistantTurn, + ContentBlock, ConversationTurn, InferenceEvent, + InferenceSource, LastCycleSource, } from "@intx/types/runtime"; import { ProtocolMismatchError } from "../errors"; +import { + createDefaultScheduler, + runInference, + type Dependencies, +} from "../harness"; +import { createBuiltinRegistry } from "../providers"; import { createAnthropicAdapter } from "./anthropic"; const TEST_SOURCE: LastCycleSource = { @@ -57,11 +66,11 @@ function pickFirstThinkingDelta( function pickFirstThinkingSignature( events: InferenceEvent[], -): Extract { +): Extract { const ev = events[0]; if (ev === undefined) throw new Error("expected at least one event"); - if (ev.type !== "inference.thinking.signature") { - throw new Error(`expected inference.thinking.signature, got ${ev.type}`); + if (ev.type !== "inference.block.signature") { + throw new Error(`expected inference.block.signature, got ${ev.type}`); } return ev; } @@ -250,24 +259,35 @@ describe("Anthropic parser — required-index schema enforcement", () => { }); }); -// All redacted_thinking test fixtures below are SYNTHETIC: derived -// from Anthropic's documented wire shape rather than from a real -// captured response. The fixture corpus carries no captured -// redacted_thinking bytes today because Anthropic's documented canary -// did not trigger the safety classifier on capture day — every -// `redacted-thinking[-streaming]` row landed in the corpus with -// `outcome: "misled"` and contains regular `thinking` blocks instead. +// Happy-path redacted_thinking fixtures use a realistic opaque `data` +// blob shaped like Anthropic wire (`{ type, data }` only — no +// signature, no thinking text). Streaming captures deliver each block +// as a one-shot `content_block_start` (no thinking_delta) and may open +// multiple redacted blocks before text. This constant is test material, +// not a live re-export of a corpus fixture path. // -// The opaque `data` payload below mimics Anthropic's format -// (long base64-looking string) but carries no real cryptographic -// content. Round-trip tests assert the harness/adapter pass the -// bytes verbatim — the actual contents are irrelevant to the -// invariant being tested. -const SYNTHETIC_REDACTED_DATA = - "ErUBCkYIBxgCKkABEHk1RmZpaWlsOXJxN0Z6cVB" + - "QcjBQYS9wQUdBQUFBQUFBQUFBQUFRQUFBQUFBQU" + - "FBQUFBQUFBQUFBQT09EhJYWXpBOXJxN0Z6cVBQc" + - "jBQYS9wAAA="; +// Adversarial unit cases (missing data, whitespace-preserving blobs) +// stay synthetic: the corpus does not cover those failure modes. Corpus +// smoke lives in the session parser regression, which replays every +// fixture-bearing capture through the adapter. +const CAPTURED_REDACTED_DATA = + "EuMFCpQBCBAYAipAk6yhHbnY8TlQ7bUY2Ji/24unHQHqjdggHcUEqfwJ30Aw" + + "0/MEN7OxckAJk+w8kg0Gb7Wa4bUBHooVkOAYSFI7TTIaY2xhdWRlLXNvbm5l" + + "dC00LTUtMjAyNTA5Mjk4AEIIdGhpbmtpbmdaJDc0MjMxNmM4LTY3MzEtNDAx" + + "Yy04ZmI3LWI0MWVjODAyM2NmMhIMseVAbj6HOCNWLgCXGgxw2hQLnMP7Yg2X" + + "gO0iMM1+zDaFQSBikq5VRaynjx4TvWDT6Oi4q17HK31nb1e2VuyxWZ9Z7eqj" + + "fdt/SudU5Cr7AzsIr6Z5Zc6fq2RyvXr6HLUBNkXZychED/tHcFDSIDQgjhJ4" + + "vUxWMezGrtdQEsusCyYwnyRDKoZx7DpKUMPiIRksfaF+rom+wqrCSVY73qZ/" + + "NJUEMmAVi+nnRisMgiwENaBJAKaT5fqa7x1BVybPsG+ZLNoDOze4F5sacFbz" + + "uT3bRBod6Jo7gf4MueX5eKE7zegLIQK4frHtxeCKCBbkVjCFICasdTZmK6Fw" + + "IP42peQjuyMLevmw+1jD860CSMOI0EUVXjsGbfMOd8Wu6J0myLPF59ca9xAz" + + "4cZp4nUazbUz7WGJ4Zi8rOIC2Ebx1mQIvu02mRla3wphm48z9UgKhMThVn3q" + + "5+sJPZhuQ8d5UbIM5ZJvlQ4Kho+XE+H7GjMi8iArTh2GbhXNA7y2y/uYfOQW" + + "XRnro7oIfHJ6CpIIZWp4nQ1vHA+kRyNa4yB5JXUSuwVhTCmaFroP3AT3ydrp" + + "OnTnMPt06DY4p+SarntDeHp5XB3n7Gf6Zmk+rnAFC+EA9nnIk/IYmeCRJaSv" + + "fMnBoAcLAj99bZoH3K9/AKcc4M2t63j9lrcjOfg1Ozy/rvBeUH4R0uqRM55G" + + "5aau5fmxAp1ERjs9RAdPGcqdeWqlvebTeMXTrPnWfXf2l+hQrs5f1+grwmsa" + + "OO4UA7P9uJJj6FbOD1z4bVuya9ZrhxgB"; function pickFirstThinkingRedacted( events: InferenceEvent[], @@ -288,21 +308,20 @@ describe("Anthropic parser — redacted_thinking content_block_start", () => { index: 0, content_block: { type: "redacted_thinking", - data: SYNTHETIC_REDACTED_DATA, + data: CAPTURED_REDACTED_DATA, }, }); expect(events).toHaveLength(1); const ev = pickFirstThinkingRedacted(events); expect(ev.data.index).toBe(0); expect(ev.data.redactedThinking.type).toBe("redacted_thinking"); - expect(ev.data.redactedThinking.data).toBe(SYNTHETIC_REDACTED_DATA); + expect(ev.data.redactedThinking.data).toBe(CAPTURED_REDACTED_DATA); }); test("preserves the data verbatim — no normalization or transformation", () => { const adapter = createAnthropicAdapter(TEST_SOURCE); - // The data is an opaque blob; any mutation breaks the round-trip. - // Use a string with characters that an over-eager normalizer would - // touch (newlines, whitespace, base64 padding). + // Adversarial: the corpus never delivers whitespace-laden data; + // this unit case still pins the no-mutation invariant. const adversarial = "abc\n ==\r\n\tdef=="; const events = parse(adapter, { type: "content_block_start", @@ -332,6 +351,37 @@ describe("Anthropic parser — redacted_thinking content_block_start", () => { expect(thrown.message).toMatch(/index 4/); } }); + + test("emits one redacted event per content_block_start when multiple appear", () => { + // Streaming captures open several redacted_thinking blocks before + // text (Haiku turn-1 streaming had multiple). Each start is + // independent and carries its own data + index. + const adapter = createAnthropicAdapter(TEST_SOURCE); + const first = parse(adapter, { + type: "content_block_start", + index: 0, + content_block: { + type: "redacted_thinking", + data: CAPTURED_REDACTED_DATA, + }, + }); + const second = parse(adapter, { + type: "content_block_start", + index: 1, + content_block: { + type: "redacted_thinking", + data: CAPTURED_REDACTED_DATA + "x", + }, + }); + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + const a = pickFirstThinkingRedacted(first); + const b = pickFirstThinkingRedacted(second); + expect(a.data.index).toBe(0); + expect(b.data.index).toBe(1); + expect(a.data.redactedThinking.data).toBe(CAPTURED_REDACTED_DATA); + expect(b.data.redactedThinking.data).toBe(CAPTURED_REDACTED_DATA + "x"); + }); }); describe("Anthropic adapter — redacted_thinking parser-to-builder round-trip", () => { @@ -348,7 +398,7 @@ describe("Anthropic adapter — redacted_thinking parser-to-builder round-trip", index: 0, content_block: { type: "redacted_thinking", - data: SYNTHETIC_REDACTED_DATA, + data: CAPTURED_REDACTED_DATA, }, }); const ev = pickFirstThinkingRedacted(events); @@ -362,12 +412,8 @@ describe("Anthropic adapter — redacted_thinking parser-to-builder round-trip", }); // The structural shape of the body is asserted elsewhere — here // we care only that the opaque `data` survives the round-trip. - // Use a structural extraction via JSON.parse + cast through unknown - // because the integration-style assertion lives in the broader - // tests/inference/providers/anthropic.test.ts and is already - // exercised. const bodyText = req.body; - expect(bodyText).toContain(SYNTHETIC_REDACTED_DATA); + expect(bodyText).toContain(CAPTURED_REDACTED_DATA); expect(bodyText).toContain(`"type":"redacted_thinking"`); }); }); @@ -733,7 +779,7 @@ describe("Anthropic adapter — responseFormat boundary", () => { test("omitted responseFormat builds a request without throwing", () => { const adapter = createAnthropicAdapter(TEST_SOURCE); - const req = adapter.buildRequest(conversation, "claude-sonnet-4", {}); + const req = adapter.buildRequest(conversation, "claude-sonnet-5", {}); expect(req.url).toBe("/v1/messages"); }); @@ -742,7 +788,7 @@ describe("Anthropic adapter — responseFormat boundary", () => { // here rather than a throw so the cross-provider call site can // pass `{ kind: "text" }` uniformly without conditional logic. const adapter = createAnthropicAdapter(TEST_SOURCE); - const req = adapter.buildRequest(conversation, "claude-sonnet-4", { + const req = adapter.buildRequest(conversation, "claude-sonnet-5", { responseFormat: { kind: "text" }, }); expect(req.url).toBe("/v1/messages"); @@ -751,7 +797,7 @@ describe("Anthropic adapter — responseFormat boundary", () => { test("responseFormat.kind=json throws at the marshaling boundary", () => { const adapter = createAnthropicAdapter(TEST_SOURCE); expect(() => - adapter.buildRequest(conversation, "claude-sonnet-4", { + adapter.buildRequest(conversation, "claude-sonnet-5", { responseFormat: { kind: "json" }, }), ).toThrow(/does not support structured outputs/); @@ -760,7 +806,7 @@ describe("Anthropic adapter — responseFormat boundary", () => { test("responseFormat.kind=json-schema throws and names the kind", () => { const adapter = createAnthropicAdapter(TEST_SOURCE); expect(() => - adapter.buildRequest(conversation, "claude-sonnet-4", { + adapter.buildRequest(conversation, "claude-sonnet-5", { responseFormat: { kind: "json-schema", name: "user", @@ -817,3 +863,359 @@ describe("Anthropic adapter — tool-name codec round-trip", () => { expect(pickFirstToolCallStart(events).data.name).toBe(PREFIXED); }); }); + +const JSON_SOURCE: InferenceSource = { + id: "anthropic:claude-test", + provider: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "test", + model: "claude-test", +}; + +// Drives a response body through the real harness accumulator and returns the +// assembled assistant turn plus every emitted event. Asserting on the decoded +// turn (rather than the raw event array) is deliberate: the accumulator +// silently drops unmodeled event types, ignores tool-call deltas with no open +// call, and swallows argument-parse failures, so only the turn reflects +// whether the parser actually decoded correctly. The content-type selects the +// harness decode path (JSON body vs SSE stream), which lets one helper drive +// both parseJSONResponse and parseResponse for cross-path parity. +async function driveTurn( + body: string, + contentType = "application/json", +): Promise<{ + turn: AssistantTurn | undefined; + events: InferenceEvent[]; +}> { + const deps: Dependencies = { + fetch: () => + Promise.resolve( + new Response(body, { + status: 200, + headers: { "content-type": contentType }, + }), + ), + scheduler: createDefaultScheduler(), + adapters: createBuiltinRegistry(), + }; + let seq = 0; + const events: InferenceEvent[] = []; + for await (const ev of runInference({ + turns: [ + { role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }, + ], + source: JSON_SOURCE, + nextSeq: () => ++seq, + deps, + })) { + events.push(ev); + } + const done = events.find( + (e): e is Extract => + e.type === "inference.done", + ); + return { turn: done?.data.turn, events }; +} + +function blocksOfType( + turn: AssistantTurn, + blockType: T, +): Extract[] { + return turn.content.filter( + (b): b is Extract => b.type === blockType, + ); +} + +function requireTurn(turn: AssistantTurn | undefined): AssistantTurn { + if (turn === undefined) throw new Error("expected an inference.done turn"); + return turn; +} + +describe("createAnthropicAdapter — parseJSONResponse (non-streaming)", () => { + test("decodes a plain-text message into a text block and usage", async () => { + const body = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [{ type: "text", text: "The capital of France is Paris." }], + stop_reason: "end_turn", + usage: { input_tokens: 20, output_tokens: 10 }, + }); + const { turn, events } = await driveTurn(body); + const t = requireTurn(turn); + expect(blocksOfType(t, "text").map((b) => b.text)).toEqual([ + "The capital of France is Paris.", + ]); + const done = events.find( + (e): e is Extract => + e.type === "inference.done", + ); + expect(done?.data.usage.input).toBe(20); + expect(done?.data.usage.output).toBe(10); + }); + + test("decodes a tool_use block into a tool call with parsed arguments", async () => { + const body = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "get_weather", + input: { location: "Boston, MA" }, + }, + ], + stop_reason: "tool_use", + usage: { input_tokens: 5, output_tokens: 3 }, + }); + const t = requireTurn((await driveTurn(body)).turn); + const calls = blocksOfType(t, "tool_call"); + expect(calls).toHaveLength(1); + const call = calls[0]; + if (call === undefined) throw new Error("expected a tool call"); + expect(call.name).toBe("get_weather"); + expect(call.id).toBe("toolu_1"); + expect(call.arguments).toEqual({ location: "Boston, MA" }); + }); + + test("decodes a thinking block with its signature", async () => { + const body = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [ + { + type: "thinking", + thinking: "Let me consider.", + signature: "sig-abc", + }, + { type: "text", text: "Answer." }, + ], + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 3 }, + }); + const t = requireTurn((await driveTurn(body)).turn); + const thinking = blocksOfType(t, "thinking"); + expect(thinking).toHaveLength(1); + const block = thinking[0]; + if (block === undefined) throw new Error("expected a thinking block"); + expect(block.thinking).toBe("Let me consider."); + expect(block.signature).toBe("sig-abc"); + }); + + test("decodes a redacted_thinking block, preserving its opaque data", async () => { + const body = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [{ type: "redacted_thinking", data: "opaque-blob" }], + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 3 }, + }); + const t = requireTurn((await driveTurn(body)).turn); + expect(blocksOfType(t, "redacted_thinking").map((b) => b.data)).toEqual([ + "opaque-blob", + ]); + }); + + test("emits citations from a text block's inline citations", async () => { + const body = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [ + { + type: "text", + text: "Grounded answer.", + citations: [ + { + type: "web_search_result_location", + cited_text: "the source text", + url: "https://example.com/a", + title: "Example A", + }, + ], + }, + ], + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 3 }, + }); + const t = requireTurn((await driveTurn(body)).turn); + const citations = blocksOfType(t, "citation"); + expect(citations).toHaveLength(1); + const citation = citations[0]; + if (citation === undefined) throw new Error("expected a citation block"); + expect(citation.citedText).toBe("the source text"); + expect(citation.source.uri).toBe("https://example.com/a"); + }); + + test("ignores unmodeled server-tool blocks without failing the turn", async () => { + const body = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [ + { type: "server_tool_use", id: "srv_1", name: "web_search", input: {} }, + { type: "text", text: "Result." }, + ], + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 3 }, + }); + const { turn, events } = await driveTurn(body); + const t = requireTurn(turn); + expect(blocksOfType(t, "text").map((b) => b.text)).toEqual(["Result."]); + expect(events.some((e) => e.type === "inference.error")).toBe(false); + }); + + test("surfaces a protocol mismatch on a non-message body", async () => { + // A streaming SSE event object (not a whole message) must not decode as a + // non-streaming response. + const body = JSON.stringify({ type: "message_start", message: {} }); + const { events } = await driveTurn(body); + const error = events.find( + (e): e is Extract => + e.type === "inference.error", + ); + if (error === undefined) throw new Error("expected inference.error"); + expect(error.data.error.category).toBe("protocol_mismatch"); + }); +}); + +function sse(events: object[]): string { + return events.map((e) => `event: x\ndata: ${JSON.stringify(e)}\n\n`).join(""); +} + +describe("createAnthropicAdapter — streaming vs non-streaming parity", () => { + // The whole point of parseJSONResponse is that a replayed non-streaming + // capture decodes to the same turn its streaming sibling would. Drive one + // logically-equivalent multi-block response through both harness decode + // paths and assert the assembled turn and final usage are identical, so a + // future edit that drifts one path's index/callId/usage/citation semantics + // from the other cannot pass in isolation. + test("a multi-block turn decodes identically through both paths", async () => { + const jsonBody = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [ + { + type: "text", + text: "Grounded.", + citations: [ + { + type: "web_search_result_location", + cited_text: "src", + url: "https://example.com/a", + title: "A", + }, + ], + }, + { type: "thinking", thinking: "hmm", signature: "sig-1" }, + { type: "redacted_thinking", data: "blob" }, + { + type: "tool_use", + id: "toolu_1", + name: "get_weather", + input: { location: "Boston" }, + }, + ], + stop_reason: "tool_use", + usage: { input_tokens: 20, output_tokens: 10 }, + }); + + const streamBody = sse([ + { + type: "message_start", + message: { usage: { input_tokens: 20, output_tokens: 0 } }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Grounded." }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "citations_delta", + citation: { + type: "web_search_result_location", + cited_text: "src", + url: "https://example.com/a", + title: "A", + }, + }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "thinking" }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "thinking_delta", thinking: "hmm" }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "signature_delta", signature: "sig-1" }, + }, + { type: "content_block_stop", index: 1 }, + { + type: "content_block_start", + index: 2, + content_block: { type: "redacted_thinking", data: "blob" }, + }, + { type: "content_block_stop", index: 2 }, + { + type: "content_block_start", + index: 3, + content_block: { type: "tool_use", id: "toolu_1", name: "get_weather" }, + }, + { + type: "content_block_delta", + index: 3, + delta: { + type: "input_json_delta", + partial_json: '{"location":"Boston"}', + }, + }, + { type: "content_block_stop", index: 3 }, + { type: "message_delta", usage: { output_tokens: 10 } }, + { type: "message_stop" }, + ]); + + const jsonResult = await driveTurn(jsonBody, "application/json"); + const streamResult = await driveTurn(streamBody, "text/event-stream"); + + expect(jsonResult.events.some((e) => e.type === "inference.error")).toBe( + false, + ); + expect(streamResult.events.some((e) => e.type === "inference.error")).toBe( + false, + ); + + const jt = requireTurn(jsonResult.turn); + const st = requireTurn(streamResult.turn); + expect(jt.content).toEqual(st.content); + + const jdone = jsonResult.events.find( + (e): e is Extract => + e.type === "inference.done", + ); + const sdone = streamResult.events.find( + (e): e is Extract => + e.type === "inference.done", + ); + expect(jdone?.data.usage).toEqual(sdone?.data.usage); + }); +}); diff --git a/vendor/intx-inference/src/providers/anthropic.ts b/vendor/intx-inference/src/providers/anthropic.ts index bc7272638..85b2baa91 100644 --- a/vendor/intx-inference/src/providers/anthropic.ts +++ b/vendor/intx-inference/src/providers/anthropic.ts @@ -10,7 +10,10 @@ import type { PartialMessage, TokenUsage, } from "@intx/types/runtime"; -import { CitationBlock as CitationBlockType } from "@intx/types/runtime"; +import { + CitationBlock as CitationBlockType, + formatSafetyRatingText, +} from "@intx/types/runtime"; import type { ProviderAdapter, BuiltRequest } from "../adapter"; import { CREDENTIAL_SENTINEL } from "../auth"; import { ProtocolMismatchError } from "../errors"; @@ -28,6 +31,15 @@ const ANTHROPIC_TOOL_NAME_LIMIT: ToolNameLimit = { maxLength: 128, }; +// Models that reject thinking:{type:"enabled",budget_tokens} and require +// thinking:{type:"adaptive"} with output_config.effort. Keep aligned with +// the discovery plug-in's ADAPTIVE_THINKING_MODELS set. +const ADAPTIVE_THINKING_MODELS: ReadonlySet = new Set([ + "claude-sonnet-5", + "claude-opus-5", + "claude-fable-5", +]); + // --------------------------------------------------------------------------- // Request building // --------------------------------------------------------------------------- @@ -78,10 +90,18 @@ function buildRequest( } if (options.thinking?.enabled) { - body["thinking"] = { - type: "enabled", - budget_tokens: options.thinking.budgetTokens ?? 1024, - }; + // Adaptive models reject the classic budget_tokens shape with + // invalid_request_error and require thinking:{type:"adaptive"} + // plus output_config.effort. + if (ADAPTIVE_THINKING_MODELS.has(model)) { + body["thinking"] = { type: "adaptive" }; + body["output_config"] = { effort: "high" }; + } else { + body["thinking"] = { + type: "enabled", + budget_tokens: options.thinking.budgetTokens ?? 1024, + }; + } } if (options.tools !== undefined && options.tools.length > 0) { @@ -135,7 +155,18 @@ function toAnthropicMessage( cacheLastBlock?: boolean, ): Record { const role = msg.role === "assistant" ? "assistant" : "user"; - const content = msg.content.map(toAnthropicBlock); + // safety_rating is Gemini output-only metadata. Rewrite as text so + // role alternation and the block reason survive Anthropic history + // without a native safety_rating input shape. + const content = msg.content.map((block) => { + if (block.type === "safety_rating") { + return toAnthropicBlock({ + type: "text", + text: formatSafetyRatingText(block), + }); + } + return toAnthropicBlock(block); + }); if (cacheLastBlock) { const lastBlock = content[content.length - 1]; if (lastBlock !== undefined) { @@ -310,7 +341,12 @@ function toAnthropicBlock(block: ContentBlock): Record { return { type: "image", source: toAnthropicMediaSource(block.source) }; case "document": - return { type: "document", source: toAnthropicMediaSource(block.source) }; + return { + type: "document", + source: toAnthropicMediaSource(block.source), + ...(block.title !== undefined ? { title: block.title } : {}), + ...(block.context !== undefined ? { context: block.context } : {}), + }; case "audio": case "video": @@ -323,6 +359,13 @@ function toAnthropicBlock(block: ContentBlock): Record { "Anthropic adapter does not yet emit citation content blocks.", ); + case "safety_rating": + // Rewritten to text in toAnthropicMessage before this switch. + throw new Error( + "Anthropic adapter: safety_rating blocks must be rewritten to " + + "text before toAnthropicBlock.", + ); + case "code_execution_request": case "code_execution_result": throw new Error( @@ -520,6 +563,25 @@ const AnthropicSSEEvent = ContentBlockDelta.or(ContentBlockStart) .or(MessageStop) .or(Ping); +// Maps Anthropic's wire usage object onto the internal TokenUsage. Anthropic +// never reports a distinct thinking-token count, so `thinking` is always 0. +// Shared by the streaming `message_start` path and the non-streaming +// `parseJSONResponse`, whose usage objects carry the same field names. +function toInferenceUsage(usage: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; +}): TokenUsage { + return { + input: usage.input_tokens ?? 0, + output: usage.output_tokens ?? 0, + cacheRead: usage.cache_read_input_tokens ?? 0, + cacheWrite: usage.cache_creation_input_tokens ?? 0, + thinking: 0, + }; +} + function parseResponse( sseData: string, blockIndexToCallId: Map, @@ -590,7 +652,7 @@ function parseResponse( const signature = delta.signature ?? ""; return [ { - type: "inference.thinking.signature", + type: "inference.block.signature", seq, data: { signature, index }, }, @@ -752,18 +814,11 @@ function parseResponse( const msgUsage = event.message?.usage; if (msgUsage === undefined) return []; - const inferenceUsage: TokenUsage = { - input: msgUsage.input_tokens ?? 0, - output: msgUsage.output_tokens ?? 0, - cacheRead: msgUsage.cache_read_input_tokens ?? 0, - cacheWrite: msgUsage.cache_creation_input_tokens ?? 0, - thinking: 0, - }; return [ { type: "inference.usage", seq, - data: { usage: inferenceUsage, source }, + data: { usage: toInferenceUsage(msgUsage), source }, }, ]; } @@ -774,6 +829,231 @@ function parseResponse( } } +// --------------------------------------------------------------------------- +// Non-streaming response parsing +// +// The non-streaming Messages endpoint returns the same content blocks the +// streaming protocol delivers incrementally, delivered whole in one JSON +// body. `parseJSONResponse` re-expresses each complete block as the same +// InferenceEvent vocabulary `parseResponse` emits, so a replayed +// non-streaming capture feeds the harness accumulator identically to its +// streaming sibling. Block types the streaming parser does not model +// (server_tool_use, web_search_tool_result, code_execution_tool_result) +// emit nothing here too; bringing those cells to parity across both paths is +// owned by the strict-mode replay regression, not this parser. +// --------------------------------------------------------------------------- + +const NonStreamingUsage = type({ + "input_tokens?": "number", + "output_tokens?": "number", + "cache_read_input_tokens?": "number", + "cache_creation_input_tokens?": "number", +}); + +const NonStreamingMessage = type({ + type: "'message'", + content: "unknown[]", + usage: NonStreamingUsage, +}); + +const BlockTag = type({ type: "string" }); + +const NonStreamingTextBlock = type({ + type: "'text'", + "text?": "string", + "citations?": AnthropicCitation.array(), +}); + +const NonStreamingToolUseBlock = type({ + type: "'tool_use'", + "id?": "string", + "name?": "string", + "input?": "unknown", +}); + +const NonStreamingThinkingBlock = type({ + type: "'thinking'", + "thinking?": "string", + "signature?": "string", +}); + +const NonStreamingRedactedThinkingBlock = type({ + type: "'redacted_thinking'", + "data?": "string", +}); + +function parseJSONResponse( + body: string, + source: LastCycleSource, +): InferenceEvent[] { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: malformed JSON response body: ${message}`, + body, + ); + } + + const message = NonStreamingMessage(parsed); + if (message instanceof type.errors) { + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: response failed schema validation: ${message.summary}`, + parsed, + ); + } + + // The seq field is a placeholder 0 — the harness assigns real sequence + // numbers, exactly as on the streaming path. + const seq = 0; + const events: InferenceEvent[] = []; + + message.content.forEach((rawBlock, index) => { + const tagged = BlockTag(rawBlock); + if (tagged instanceof type.errors) { + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: content block ${String(index)} has no string type: ${tagged.summary}`, + rawBlock, + ); + } + + switch (tagged.type) { + case "text": { + const block = NonStreamingTextBlock(rawBlock); + if (block instanceof type.errors) { + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: text block ${String(index)} failed validation: ${block.summary}`, + rawBlock, + ); + } + events.push({ + type: "inference.text.delta", + seq, + data: { token: block.text ?? "", partial: EMPTY_PARTIAL, index }, + }); + // The streaming path emits one inference.citation per citations_delta + // keyed to the enclosing text block's index; the non-streaming shape + // carries those same citations inline on the block. + for (const citation of block.citations ?? []) { + events.push({ + type: "inference.citation", + seq, + data: { citation: toCitationBlock(citation, index), index }, + }); + } + break; + } + + case "tool_use": { + const block = NonStreamingToolUseBlock(rawBlock); + if (block instanceof type.errors) { + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: tool_use block ${String(index)} failed validation: ${block.summary}`, + rawBlock, + ); + } + // callId falls back to the block index exactly as the streaming + // content_block_start does, so a tool_use block with no id still + // correlates its start and args delta. + const callId = block.id ?? String(index); + events.push({ + type: "inference.tool_call.start", + seq, + data: { + callId, + name: decodeToolName(block.name ?? ""), + partial: EMPTY_PARTIAL, + index, + }, + }); + events.push({ + type: "inference.tool_call.delta", + seq, + data: { + callId, + argumentFragment: JSON.stringify(block.input ?? {}), + partial: EMPTY_PARTIAL, + index, + }, + }); + break; + } + + case "thinking": { + const block = NonStreamingThinkingBlock(rawBlock); + if (block instanceof type.errors) { + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: thinking block ${String(index)} failed validation: ${block.summary}`, + rawBlock, + ); + } + // Emit the thinking delta first so the harness has a thinking block + // at this index before the signature arrives; a signature with no + // preceding thinking entry is a protocol violation the harness + // rejects. + events.push({ + type: "inference.thinking.delta", + seq, + data: { token: block.thinking ?? "", partial: EMPTY_PARTIAL, index }, + }); + if (block.signature !== undefined) { + events.push({ + type: "inference.block.signature", + seq, + data: { signature: block.signature, index }, + }); + } + break; + } + + case "redacted_thinking": { + const block = NonStreamingRedactedThinkingBlock(rawBlock); + if (block instanceof type.errors) { + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: redacted_thinking block ${String(index)} failed validation: ${block.summary}`, + rawBlock, + ); + } + // The opaque `data` blob must echo back verbatim on follow-up turns; + // a missing `data` is a protocol violation, not a default-to-empty + // case, matching the streaming redacted_thinking handling. + if (block.data === undefined) { + throw new ProtocolMismatchError( + `anthropic parseJSONResponse: redacted_thinking block ${String(index)} missing required \`data\` field`, + rawBlock, + ); + } + events.push({ + type: "inference.thinking.redacted", + seq, + data: { + redactedThinking: { type: "redacted_thinking", data: block.data }, + index, + }, + }); + break; + } + + default: + // server_tool_use, web_search_tool_result, + // code_execution_tool_result, and any future block type: the + // streaming parser emits nothing for these, so mirror that rather + // than diverge from a path with no passing reference yet. + break; + } + }); + + events.push({ + type: "inference.usage", + seq, + data: { usage: toInferenceUsage(message.usage), source }, + }); + + return events; +} + function extractRetryAfterMs(headers: Headers): number | undefined { const raw = headers.get("retry-after"); if (raw === null) return undefined; @@ -808,15 +1088,32 @@ function extractPacingDelayMs(headers: Headers): number | undefined { return delays.length > 0 ? Math.max(...delays) : undefined; } +// The anthropic adapter carries no per-source accommodations today, so its +// quirks shape is empty. A quirks bag is deployment configuration crossing +// into the system at this boundary; rejecting unknown keys makes a +// misconfigured bag — for example an openai quirk pasted onto an anthropic +// source — fail loudly here rather than run silently ignored. +export const AnthropicQuirks = type({ "+": "reject" }); +export type AnthropicQuirks = typeof AnthropicQuirks.infer; + export function createAnthropicAdapter( source: LastCycleSource, + quirks?: unknown, ): ProviderAdapter { + const parsedQuirks = AnthropicQuirks(quirks ?? {}); + if (parsedQuirks instanceof type.errors) { + throw new Error( + `anthropic adapter: invalid quirks: ${parsedQuirks.summary}`, + ); + } + const blockIndexToCallId = new Map(); return { buildRequest, parseResponse: (sseData) => parseResponse(sseData, blockIndexToCallId, source), + parseJSONResponse: (body) => parseJSONResponse(body, source), extractRetryAfterMs, extractPacingDelayMs, }; diff --git a/vendor/intx-inference/src/providers/google-genai-files.ts b/vendor/intx-inference/src/providers/google-genai-files.ts index 54bac04ca..1bdf855e8 100644 --- a/vendor/intx-inference/src/providers/google-genai-files.ts +++ b/vendor/intx-inference/src/providers/google-genai-files.ts @@ -170,6 +170,7 @@ export async function uploadGoogleGenAIFile( method: "POST", headers, // DOM lib BodyInit is narrower than Node's Uint8Array typing; fetch accepts bytes. + // Locally patched — see vendor/intx-inference/PATCHES.md#google-genai-files-ts body: opts.bytes as unknown as BodyInit, }; // `RequestInit.signal` is typed as `AbortSignal | null` under diff --git a/vendor/intx-inference/src/providers/google-genai.ts b/vendor/intx-inference/src/providers/google-genai.ts index aef51b406..3e4069a16 100644 --- a/vendor/intx-inference/src/providers/google-genai.ts +++ b/vendor/intx-inference/src/providers/google-genai.ts @@ -12,6 +12,7 @@ import type { PartialMessage, TokenUsage, } from "@intx/types/runtime"; +import { formatSafetyRatingText } from "@intx/types/runtime"; import type { ProviderAdapter, BuiltRequest } from "../adapter"; import { CREDENTIAL_SENTINEL } from "../auth"; import { ProtocolMismatchError } from "../errors"; @@ -29,6 +30,22 @@ const GOOGLE_TOOL_NAME_LIMIT: ToolNameLimit = { maxLength: 64, }; +// Models that reject thinkingConfig.thinkingBudget: 0 with HTTP 400. +// Keep aligned with the discovery plug-in's THINKING_MANDATORY_MODELS. +const THINKING_MANDATORY_MODELS: ReadonlySet = new Set([ + "gemini-2.5-pro", + "gemini-3.6-flash", +]); + +// Dynamic thinking budget sentinel: the model decides how much to +// think. Used when suppressing thought parts on thinking-mandatory +// models that reject a zero budget. +const DYNAMIC_THINKING_BUDGET = -1; + +function minimalThinkingBudget(model: string): number { + return THINKING_MANDATORY_MODELS.has(model) ? DYNAMIC_THINKING_BUDGET : 0; +} + // Runtime validator for "parsed JSON value is a plain object." Used // by `tryParseJSONObject` to narrow `JSON.parse(string)` from its // declared `unknown` return into a `Record` without a @@ -43,10 +60,6 @@ const ParsedJSONObject = type("Record"); // Translates the internal ConversationTurn[] format into Gemini's // `generateContent` / `streamGenerateContent` request body. The harness // always streams, so the URL pins `:streamGenerateContent?alt=sse`. -// -// `parseResponse` throws unconditionally: a live call surfaces the -// missing parser via the harness's standard inference.error path -// rather than silently dropping events. // --------------------------------------------------------------------------- function buildRequest( @@ -88,9 +101,20 @@ function buildRequest( // walk would be O(N^2) in turn count. const callIdToFunctionName = buildCallIdToFunctionName(messages); - const contents: GeminiContent[] = conversationMessages.map((msg) => - toGeminiContent(msg, callIdToFunctionName), - ); + // safety_rating is output-only. Rewrite to text so multi-turn history + // keeps role alternation and a model-visible block reason (same + // policy as Anthropic/OpenAI/transform). + const contents: GeminiContent[] = conversationMessages.map((msg) => { + const rewritten: ConversationTurn = { + ...msg, + content: msg.content.map((b) => + b.type === "safety_rating" + ? { type: "text" as const, text: formatSafetyRatingText(b) } + : b, + ), + }; + return toGeminiContent(rewritten, callIdToFunctionName); + }); const body: Record = { contents }; @@ -110,7 +134,7 @@ function buildRequest( ]; } - const generationConfig = buildGenerationConfig(options); + const generationConfig = buildGenerationConfig(model, options); if (generationConfig !== undefined) { body["generationConfig"] = generationConfig; } @@ -151,9 +175,9 @@ function buildRequest( // Round-trip wire shapes. `thought` and `thoughtSignature` are // Gemini-specific metadata that ride alongside the payload-bearing // fields; both are optional on every part. The translation produces -// a `text` part with `thought: true` for `ThinkingBlock`s, and -// stashes signatures onto the follow-on non-thinking part per the -// pairing logic in `toGeminiContent`. +// a `text` part with `thought: true` for `ThinkingBlock`s, and rides +// each block's `signature` back as a `thoughtSignature` on that +// block's own part. interface GeminiTextPart { text: string; thought?: boolean; @@ -232,63 +256,15 @@ function toGeminiContent( } } - // Positional signature pairing: a `ThinkingBlock` with a signature - // contributes both a `{text, thought: true}` part (no signature on - // it) and a stashed signature that attaches to the NEXT - // non-thinking part in the turn. The wire convention from the - // captured fixtures places `thoughtSignature` on the follow-on - // part (typically `functionCall`), not on the thinking text. Two - // pending signatures in a row, or a turn ending with a signature - // still pending, are encoded as errors: the corpus contains no - // fixture for those shapes and a silent drop would corrupt the - // signed-thinking round-trip Gemini requires. - const parts: GeminiPart[] = []; - let pendingSignature: string | null = null; - for (const block of msg.content) { - const part = toGeminiPart(block, callIdToFunctionName); - const isThinkingPart = - "text" in part && (part as GeminiTextPart).thought === true; - if (isThinkingPart) { - if (pendingSignature !== null) { - throw new Error( - `Google GenAI adapter: encountered a second thinking block on ` + - `assistant turn while a prior thinking-block signature is ` + - `still awaiting a carrier part; the wire convention pairs ` + - `each signed thinking block 1:1 with the next non-thinking ` + - `part.`, - ); - } - // Stash the signature off the thinking block (if any) for the - // next non-thinking part to claim. `toGeminiPart` already - // produced a thinking part WITHOUT the signature on it, per - // the wire shape. - if (block.type === "thinking" && block.signature !== undefined) { - pendingSignature = block.signature; - } - parts.push(part); - continue; - } - - if (pendingSignature !== null) { - // Attach the stashed signature to this non-thinking part. The - // mutation matches Gemini's wire shape exactly: the part keeps - // its existing payload and grows a `thoughtSignature` field. - (part as GeminiPart & { thoughtSignature?: string }).thoughtSignature = - pendingSignature; - pendingSignature = null; - } - parts.push(part); - } - - if (pendingSignature !== null) { - throw new Error( - `Google GenAI adapter: assistant turn ends with a thinking-block ` + - `signature awaiting a carrier part. Gemini's wire convention ` + - `requires the signature to ride on a follow-on non-thinking part ` + - `(typically a functionCall); a signed thinking block with no ` + - `follow-on part has no defined wire shape.`, - ); - } + // Each block carries its own signature; `toGeminiPart` rides it back + // onto that block's own part as a `thoughtSignature`. The captured + // wire places the signature on whichever part the model signed (for a + // signed thinking turn, that is the follow-on functionCall part, which + // reverse-parsing attributed to the tool_call block), so a per-block + // round-trip reproduces the wire without any cross-part pairing. + const parts = msg.content.map((block) => + toGeminiPart(block, callIdToFunctionName), + ); return { role, parts }; } @@ -299,9 +275,22 @@ function toGeminiPart( ): GeminiPart { switch (block.type) { case "text": - return { text: block.text }; + return { + text: block.text, + ...(block.signature !== undefined + ? { thoughtSignature: block.signature } + : {}), + }; + + case "image": { + // Only ImageBlock among the media kinds carries a signature; the + // others have no signature field to ride back. + const part = toGeminiMediaPart(block.source); + return block.signature !== undefined + ? { ...part, thoughtSignature: block.signature } + : part; + } - case "image": case "document": case "audio": case "video": @@ -313,21 +302,27 @@ function toGeminiPart( name: encodeToolName(block.name, GOOGLE_TOOL_NAME_LIMIT), args: block.arguments, }, + ...(block.signature !== undefined + ? { thoughtSignature: block.signature } + : {}), }; case "tool_result": return toGeminiFunctionResponse(block, callIdToFunctionName); case "thinking": - // Thinking text is translated WITHOUT the signature on this - // part. `toGeminiContent`'s positional pairing logic stashes - // the signature off the block and attaches it to the next - // non-thinking part in the same turn (which is where Gemini's - // wire format expects to see `thoughtSignature`). If the - // signature were attached here, both this part and the - // following part would carry it, producing a malformed - // request. - return { text: block.thinking, thought: true }; + // A thinking block rides its own signature on its part, the same + // as any other block. Gemini most often signs the follow-on + // functionCall part instead, which reverse-parsing attributes to + // the tool_call block, so a signed thinking part here is the rare + // case where Gemini signed the thought itself. + return { + text: block.thinking, + thought: true, + ...(block.signature !== undefined + ? { thoughtSignature: block.signature } + : {}), + }; case "redacted_thinking": // Gemini does not emit redacted-thinking blocks; a caller @@ -338,6 +333,12 @@ function toGeminiPart( "they are Anthropic-specific.", ); + case "safety_rating": + // Rewritten to text in buildRequest before toGeminiPart is called. + throw new Error( + "Google GenAI adapter: safety_rating blocks must be rewritten " + + "to text before toGeminiPart.", + ); case "citation": // Citations are output-only blocks: the model produces them as // grounding/source references for its own text. Echoing one @@ -502,6 +503,7 @@ function tryParseJSONObject(text: string): Record | null { // --------------------------------------------------------------------------- function buildGenerationConfig( + model: string, options: InferenceOptions, ): Record | undefined { const config: Record = {}; @@ -515,10 +517,10 @@ function buildGenerationConfig( // thinking.enabled === true -> include a budget (default 1024) and // ask Gemini to surface thought parts - // thinking.enabled === false -> set the budget to 0 to disable - // thinking; Gemini's 2.5-series default - // is NOT zero, so "thinking off" needs - // an explicit signal + // thinking.enabled === false -> suppress thoughts: budget 0 when the + // model allows it, or dynamic (-1) for + // thinking-mandatory models that reject + // a zero budget with HTTP 400 // thinking absent -> omit thinkingConfig entirely; Gemini // uses the model's default if (options.thinking !== undefined) { @@ -529,7 +531,9 @@ function buildGenerationConfig( includeThoughts: true, }; } else { - config["thinkingConfig"] = { thinkingBudget: 0 }; + config["thinkingConfig"] = { + thinkingBudget: minimalThinkingBudget(model), + }; } } @@ -742,9 +746,17 @@ const GeminiUsageMetadata = type({ "cachedContentTokenCount?": "number", }); +// Prompt-level safety signal. Captured 2026-07-28 on +// safety-classification fixtures: `{ blockReason: "PROHIBITED_CONTENT" }` +// with no candidates. Only fields we consume are validated. +const GeminiPromptFeedback = type({ + "blockReason?": "string > 0", +}); + const GeminiSSEEvent = type({ "candidates?": GeminiCandidate.array(), "usageMetadata?": GeminiUsageMetadata, + "promptFeedback?": GeminiPromptFeedback, // `modelVersion` and `responseId` are dropped at this layer. The // harness's `AssistantTurn.model` is set from the requested model // string, not from the served `modelVersion` -- which can differ @@ -770,21 +782,13 @@ const GeminiSSEEvent = type({ // Function-call blocks are atomic (a single part = a complete // tool call) and never become the `currentBlock`. // -// - `pendingSignatureAnchor` is set when a thinking block closes -// and cleared when the next non-thinking part that carries -// `thoughtSignature` attaches its signature to that index. A -// standalone signature-only part (no payload) also consumes the -// anchor. The lifecycle is deliberately narrow: keeping a -// long-lived "most recent thinking block" pointer would let a -// signature on, say, the third functionCall attach to the first -// thinking block when two unrelated functionCalls happened in -// between. The wire convention is "the signature belongs to the -// immediately preceding thinking," and the state encodes exactly -// that. +// A `thoughtSignature` is a per-part attribute: it authenticates the +// block whose part carries it, so the parser emits an +// `inference.block.signature` against that block's own index and keeps +// no cross-part signature state. interface GeminiParserState { nextBlockIndex: number; currentBlock: { kind: "text" | "thinking"; index: number } | null; - pendingSignatureAnchor: number | null; // Unmatched-request stack of depth 1: when the parser emits an // `inference.code_execution.start` for an `executableCode` part, // the synthetic request id lands here and is consumed by the @@ -802,56 +806,48 @@ function createParserState(): GeminiParserState { return { nextBlockIndex: 0, currentBlock: null, - pendingSignatureAnchor: null, pendingExecutionRequestId: null, }; } +// A `thoughtSignature` authenticates the block whose part carries it. +// Emit an `inference.block.signature` against that block's own index; +// providers that do not sign this part leave `signature` undefined and +// this emits nothing. +function emitBlockSignature( + signature: string | undefined, + index: number, + seq: number, + out: InferenceEvent[], +): void { + if (signature === undefined) return; + out.push({ + type: "inference.block.signature", + seq, + data: { signature, index }, + }); +} + // Open or extend a text/thinking block, returning the block index. // A part of the same kind as the current block extends it; a part of -// a different kind closes the current block and allocates a new -// index. Closing a thinking block stashes its index in -// `pendingSignatureAnchor` so a subsequent non-thinking part's -// `thoughtSignature` can attach to it. +// a different kind closes the current block and allocates a new index. function openOrExtendBlock( state: GeminiParserState, kind: "text" | "thinking", - rawForError: unknown, ): number { if (state.currentBlock !== null && state.currentBlock.kind === kind) { return state.currentBlock.index; } - closeCurrentBlock(state, rawForError); + closeCurrentBlock(state); const index = state.nextBlockIndex++; state.currentBlock = { kind, index }; return index; } -// Close the current text/thinking block. A thinking block being -// closed sets `pendingSignatureAnchor` so the next non-thinking part -// can claim it for its `thoughtSignature`. If two thinking blocks -// close in a row without an intervening signature consumer, surface -// it loudly -- the corpus has no fixture exercising that shape and -// silently overwriting the anchor would route a signature to the -// wrong block. -function closeCurrentBlock( - state: GeminiParserState, - rawForError: unknown, -): void { - if (state.currentBlock?.kind === "thinking") { - if (state.pendingSignatureAnchor !== null) { - throw new ProtocolMismatchError( - `google-genai parseResponse: second thinking block closed with a ` + - `prior signature anchor still pending (anchor block index ` + - `${String(state.pendingSignatureAnchor)}); the wire convention ` + - `pairs each thinking block 1:1 with the next non-thinking ` + - `carrier and the corpus contains no fixture for the unpaired ` + - `case.`, - rawForError, - ); - } - state.pendingSignatureAnchor = state.currentBlock.index; - } +// Close the current text/thinking block so the next part of any kind +// starts a fresh block. A signature rides on its own part and attaches +// to that part's block, so closing carries no signature state. +function closeCurrentBlock(state: GeminiParserState): void { state.currentBlock = null; } @@ -862,9 +858,10 @@ function closeCurrentBlock( // semantics would otherwise admit a part with more than one set, // or with `thought: true` on a non-text part. Both are wire // violations and surface as `ProtocolMismatchError` here. A part -// with zero payload fields is only legal when a `thoughtSignature` -// is present (signature-carrier-only part, not seen in the current -// corpus but spec-permitted). +// with zero payload fields passes this structural check only when a +// `thoughtSignature` is present; `emitPart` then rejects that +// signature-only part separately, since a signature with no payload +// has no block to authenticate. function assertSinglePayload( part: typeof GeminiPart.infer, raw: unknown, @@ -918,7 +915,7 @@ function emitPart( // text part with `thought: true` -- belongs to a thinking block. if (part.text !== undefined && part.thought === true) { - const index = openOrExtendBlock(state, "thinking", raw); + const index = openOrExtendBlock(state, "thinking"); // Anchor the block in the harness's per-index map. An empty // text part with only a `thoughtSignature` would otherwise route // the signature to an index the harness has never seen. The @@ -933,44 +930,22 @@ function emitPart( index, }, }); - // A thinking part may itself carry a signature (signature on the - // thinking part rather than on a follow-on functionCall). Attach - // it directly to this thinking block's index; it consumes any - // pending anchor too because the signature on `this` thinking - // part takes precedence. - if (part.thoughtSignature !== undefined) { - out.push({ - type: "inference.thinking.signature", - seq, - data: { signature: part.thoughtSignature, index }, - }); - state.pendingSignatureAnchor = null; - } + // A thinking part may carry its own signature; attach it to this + // thinking block's index. + emitBlockSignature(part.thoughtSignature, index, seq, out); return; } - // text part without `thought` -- belongs to a text block. + // text part without `thought` -- belongs to a text block. An empty + // text part with no signature is a true no-op: it neither opens nor + // closes a block, so a follow-on same-kind part extends what was + // open. An empty text part that carries a signature still opens (or + // extends) a text block so the signature has its own block to sign. if (part.text !== undefined) { - if (part.text === "") { - // Empty text parts emit no delta. A signature-bearing - // empty-text part is still the carrier opportunity for any - // open thinking block: close the current block first so the - // thinking-block index lands in `pendingSignatureAnchor`, - // then consume the signature against it. Without that claim - // path, the signature would silently evaporate (the payload - // has nowhere else to surface) -- the empty payload is the - // ONLY signal Gemini sends for an authenticated empty-text - // carrier. An empty-text part without a signature is a true - // no-op -- it neither closes the current block nor consumes - // the carrier opportunity, so a follow-on same-kind part - // extends what was open. - if (part.thoughtSignature !== undefined) { - closeCurrentBlock(state, raw); - consumeSignature(state, part.thoughtSignature, seq, out, raw); - } + if (part.text === "" && part.thoughtSignature === undefined) { return; } - const index = openOrExtendBlock(state, "text", raw); + const index = openOrExtendBlock(state, "text"); out.push({ type: "inference.text.delta", seq, @@ -980,15 +955,7 @@ function emitPart( index, }, }); - // Settle the carrier opportunity. A `thoughtSignature` on the - // part consumes the pending anchor (the signature - // authenticates the preceding thinking, not the text block); - // a signature-less part still ends the carrier opportunity by - // discarding the anchor. The wire convention is that the FIRST - // non-thinking part after a thinking block is the only carrier - // chance -- a later thinking block cannot retroactively claim - // a stale anchor. - settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw); + emitBlockSignature(part.thoughtSignature, index, seq, out); return; } @@ -996,7 +963,7 @@ function emitPart( // does not become the `currentBlock` (a follow-on text or thinking // part starts a new block of that kind). if (part.functionCall !== undefined) { - closeCurrentBlock(state, raw); + closeCurrentBlock(state); const fc = part.functionCall; const index = state.nextBlockIndex++; // Synthetic callId: Gemini's `functionCall` has no wire-level id @@ -1006,18 +973,6 @@ function emitPart( // are unique within a request by construction. const callId = String(index); - // Settle the carrier opportunity BEFORE the tool_call.start/delta - // pair. The signature event carries the thinking block's explicit - // index in its data, so the harness routes it correctly regardless - // of arrival order; the ordering here is for positional consumers - // of the event stream (snapshot tests, debuggers, anything reading - // the sequence by position rather than by index). The same settle - // call also discards a stale anchor when no signature is present, - // so a later thinking block does not trip the "two thinking - // blocks closed" guard on an anchor the current carrier already - // declined to claim. - settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw); - out.push({ type: "inference.tool_call.start", seq, @@ -1045,16 +1000,17 @@ function emitPart( index, }, }); + // A `thoughtSignature` on the functionCall part authenticates the + // tool_call block; emit it after the block is open at this index. + emitBlockSignature(part.thoughtSignature, index, seq, out); return; } // inlineData part -- atomic image-output block. The image arrives // complete in a single SSE event (no streaming chunks of base64), // so a new block index is allocated and the ImageBlock is emitted - // in one `inference.image_output` event. The signature carrier - // semantics mirror the functionCall path: any pending thinking - // signature is settled BEFORE the image_output event so it - // attaches to the preceding thinking block, not the image block. + // in one `inference.image_output` event. A `thoughtSignature` on the + // part authenticates the image block and is emitted against its index. if (part.inlineData !== undefined) { // The parser wraps inlineData as an `ImageBlock`, so a non- // image MIME (e.g. audio/wav, application/pdf) would silently @@ -1069,9 +1025,8 @@ function emitPart( raw, ); } - closeCurrentBlock(state, raw); + closeCurrentBlock(state); const index = state.nextBlockIndex++; - settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw); out.push({ type: "inference.image_output", seq, @@ -1087,6 +1042,7 @@ function emitPart( index, }, }); + emitBlockSignature(part.thoughtSignature, index, seq, out); return; } @@ -1120,9 +1076,8 @@ function emitPart( raw, ); } - closeCurrentBlock(state, raw); + closeCurrentBlock(state); const index = state.nextBlockIndex++; - settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw); const requestId = `gemini-exec-${String(index)}`; state.pendingExecutionRequestId = requestId; @@ -1144,6 +1099,9 @@ function emitPart( seq, data: { request, index }, }); + // A `thoughtSignature` on the executableCode part authenticates the + // code-execution-request block; emit it against its index. + emitBlockSignature(part.thoughtSignature, index, seq, out); return; } @@ -1172,10 +1130,19 @@ function emitPart( // part without partial side effects. const cer = part.codeExecutionResult; const status = outcomeToStatus(cer.outcome, raw); + // A code_execution_result block carries no signature field, and the + // corpus never signs a result part; a signature here is an + // unmodeled wire shape. Reject before mutating state. + if (part.thoughtSignature !== undefined) { + throw new ProtocolMismatchError( + `google-genai parseResponse: codeExecutionResult part carries a ` + + `thoughtSignature; the code_execution_result block is not signable.`, + raw, + ); + } - closeCurrentBlock(state, raw); + closeCurrentBlock(state); const index = state.nextBlockIndex++; - settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw); state.pendingExecutionRequestId = null; const result: CodeExecutionResultBlock = { @@ -1197,15 +1164,15 @@ function emitPart( return; } - // Signature-only part (no payload, signature set). A still-open - // thinking block is closed first so its index lands in - // `pendingSignatureAnchor` before `consumeSignature` claims it -- - // same shape as the empty-text-with-signature branch above. No - // new block is opened. + // Signature-only part (no payload, signature set). A signature + // authenticates a block; a part with no payload has no block to own + // it, so this is an unmodeled wire shape the corpus never exercises. if (part.thoughtSignature !== undefined) { - closeCurrentBlock(state, raw); - consumeSignature(state, part.thoughtSignature, seq, out, raw); - return; + throw new ProtocolMismatchError( + `google-genai parseResponse: part carries a thoughtSignature but no ` + + `payload; there is no block for the signature to authenticate.`, + raw, + ); } // `assertSinglePayload` above rules out the no-payload-no-signature @@ -1348,59 +1315,6 @@ function outcomeToStatus( } } -// Settle the carrier-opportunity lifecycle for a non-thinking part -// that has just been processed. If the part carries a signature, it -// is consumed against the pending anchor (which must exist, or the -// request is in a corrupt state). If it does not, the anchor is -// discarded: the FIRST non-thinking part after a thinking block is -// the only chance to claim that thinking block's signature, and a -// part that passes without claiming ends the opportunity. A later -// thinking block cannot retroactively re-open the claim, and the -// discard prevents a stale anchor from tripping the -// `closeCurrentBlock` guard when another thinking block closes. -function settleCarrierOpportunity( - state: GeminiParserState, - signature: string | undefined, - seq: number, - out: InferenceEvent[], - raw: unknown, -): void { - if (signature !== undefined) { - consumeSignature(state, signature, seq, out, raw); - return; - } - state.pendingSignatureAnchor = null; -} - -// Emit `inference.thinking.signature` against the pending anchor and -// clear it. A signature with no pending anchor is a state-corruption -// case: Gemini placed a thoughtSignature on a part with no preceding -// thinking block in this request. Surface as a protocol mismatch. -function consumeSignature( - state: GeminiParserState, - signature: string, - seq: number, - out: InferenceEvent[], - raw: unknown, -): void { - if (state.pendingSignatureAnchor === null) { - throw new ProtocolMismatchError( - `google-genai parseResponse: thoughtSignature present but no ` + - `preceding thinking block exists in this request to anchor it.`, - raw, - ); - } - out.push({ - type: "inference.thinking.signature", - seq, - data: { - signature, - index: state.pendingSignatureAnchor, - }, - }); - state.pendingSignatureAnchor = null; -} - function parseResponse( sseData: string, state: GeminiParserState, @@ -1468,6 +1382,49 @@ function parseResponse( ); } + // Prompt-level structured safety signal. Observed capture shape + // (safety-classification fixtures, 2026-07-28): HTTP 200 with + // `promptFeedback.blockReason` and zero candidates. Treat as a + // terminal parse path: emit the safety event, then usage from + // `usageMetadata` (which is present on the capture). This is not + // an `inference.error` — the transport succeeded and the wire + // carries a structured signal. + const blockReason = event.promptFeedback?.blockReason; + if (blockReason !== undefined) { + out.push({ + type: "inference.safety_rating", + seq, + data: { + safetyRating: { + type: "safety_rating", + blockReason, + }, + }, + }); + const usage = event.usageMetadata; + if (usage === undefined) { + throw new ProtocolMismatchError( + `google-genai parseResponse: promptFeedback.blockReason terminal event missing usageMetadata.`, + parsed, + ); + } + out.push({ + type: "inference.usage", + seq, + data: { + usage: { + input: usage.promptTokenCount ?? 0, + output: usage.candidatesTokenCount ?? 0, + cacheRead: usage.cachedContentTokenCount ?? 0, + cacheWrite: 0, + thinking: usage.thoughtsTokenCount ?? 0, + }, + source, + }, + }); + return out; + } + // `finishReason` arrives only on the terminal event. Emit usage at // exactly that point: Gemini's `usageMetadata` is cumulative in // every event, so the terminal-event snapshot is the final count @@ -1477,7 +1434,9 @@ function parseResponse( // `MAX_TOKENS`, `SAFETY`, `RECITATION`, and `OTHER` reach this // layer but do not yet surface as `inference.error` -- emitting // those needs fixtures showing the full error envelope shape, - // which the plain-text path does not exercise. + // which the plain-text path does not exercise. Candidate-level + // `safetyRatings` arrays have also not been observed on the + // discovery corpus; extend emission when a capture carries them. if (candidate?.finishReason !== undefined) { const usage = event.usageMetadata; if (usage === undefined) { @@ -1525,15 +1484,63 @@ function parseResponse( return out; } +// A non-streaming generateContent response is shaped exactly like a single +// terminal streaming SSE event: one GeminiSSEEvent carrying the full parts +// array and a terminal finishReason (or a promptFeedback.blockReason). Decode +// it through the same parser with a fresh per-call state, so a replayed +// non-streaming capture feeds the harness accumulator identically to its +// streaming sibling — parity by construction, since the parser's state machine +// is boundary-agnostic (nothing in it branches on SSE-event boundaries). The +// "malformed JSON in SSE data payload" message parseResponse throws on a bad +// body is path-neutral in substance (the body is JSON either way), so it is +// left shared rather than forking the streaming parser's signature. +function parseJSONResponse( + body: string, + source: LastCycleSource, +): InferenceEvent[] { + const events = parseResponse(body, createParserState(), source); + // A complete non-streaming body MUST be terminal. The shared parser + // tolerates non-terminal events (correct mid-stream, where an intermediate + // event legitimately carries no finishReason), but here a body with no + // finishReason and no promptFeedback.blockReason is a truncated or malformed + // capture, not a silent empty decode. Both terminal paths emit + // inference.usage, so its absence is the faithful terminality signal. + if (!events.some((e) => e.type === "inference.usage")) { + throw new ProtocolMismatchError( + `google-genai parseJSONResponse: non-streaming body carried no terminal ` + + `finishReason or promptFeedback.blockReason; a complete ` + + `generateContent response must be terminal and emit usage.`, + body, + ); + } + return events; +} + +// The google-genai adapter carries no per-source accommodations today, so its +// quirks shape is empty. A quirks bag is deployment configuration crossing +// into the system at this boundary; rejecting unknown keys makes a +// misconfigured bag fail loudly here rather than run silently ignored. +export const GoogleGenAIQuirks = type({ "+": "reject" }); +export type GoogleGenAIQuirks = typeof GoogleGenAIQuirks.infer; + export function createGoogleGenAIAdapter( source: LastCycleSource, + quirks?: unknown, ): ProviderAdapter { - // Per-request state lives in the closure: block-index allocation - // and signature-anchor pairing both need to span SSE events. - // `buildRequest` does not touch state; only `parseResponse` does. + const parsedQuirks = GoogleGenAIQuirks(quirks ?? {}); + if (parsedQuirks instanceof type.errors) { + throw new Error( + `google-genai adapter: invalid quirks: ${parsedQuirks.summary}`, + ); + } + + // Per-request state lives in the closure: block-index allocation and + // the code-execution request/result pairing both need to span SSE + // events. `buildRequest` does not touch state; only `parseResponse` does. const state = createParserState(); return { buildRequest, parseResponse: (sseData) => parseResponse(sseData, state, source), + parseJSONResponse: (body) => parseJSONResponse(body, source), }; } diff --git a/vendor/intx-inference/src/providers/index.test.ts b/vendor/intx-inference/src/providers/index.test.ts index c100d3923..6c650d245 100644 --- a/vendor/intx-inference/src/providers/index.test.ts +++ b/vendor/intx-inference/src/providers/index.test.ts @@ -36,6 +36,7 @@ describe("loadAdapterRegistry", () => { const customAdapter: ProviderAdapter = { buildRequest: () => ({ url: "custom", headers: {}, body: "" }), parseResponse: () => [], + parseJSONResponse: () => [], }; const customFactory: AdapterFactory = () => customAdapter; const importer: ModuleImporter = (specifier) => { diff --git a/vendor/intx-inference/src/providers/index.ts b/vendor/intx-inference/src/providers/index.ts index 941db6d50..000c29160 100644 --- a/vendor/intx-inference/src/providers/index.ts +++ b/vendor/intx-inference/src/providers/index.ts @@ -7,9 +7,9 @@ import { createAnthropicAdapter } from "./anthropic"; import { createGoogleGenAIAdapter } from "./google-genai"; import { createOpenAIAdapter } from "./openai"; -export { createAnthropicAdapter } from "./anthropic"; -export { createGoogleGenAIAdapter } from "./google-genai"; -export { createOpenAIAdapter } from "./openai"; +export { createAnthropicAdapter, AnthropicQuirks } from "./anthropic"; +export { createGoogleGenAIAdapter, GoogleGenAIQuirks } from "./google-genai"; +export { createOpenAIAdapter, OpenAIQuirks } from "./openai"; function builtinFactories(): Record { return { diff --git a/vendor/intx-inference/src/providers/openai.ts b/vendor/intx-inference/src/providers/openai.ts index 571232f0a..428d2d481 100644 --- a/vendor/intx-inference/src/providers/openai.ts +++ b/vendor/intx-inference/src/providers/openai.ts @@ -9,6 +9,7 @@ import type { PartialMessage, TokenUsage, } from "@intx/types/runtime"; +import { formatSafetyRatingText } from "@intx/types/runtime"; import type { ProviderAdapter, BuiltRequest } from "../adapter"; import { BEARER_CREDENTIAL_SENTINEL } from "../auth"; import { ProtocolMismatchError } from "../errors"; @@ -26,6 +27,46 @@ const OPENAI_TOOL_NAME_LIMIT: ToolNameLimit = { maxLength: 64, }; +// Per-source accommodations for the OpenAI-compatible backends this adapter +// serves. Every field is optional; an absent field resolves to the strict +// protocol default, so a source that supplies no quirks gets no accommodation +// and must opt into lenient behavior explicitly. +export const OpenAIQuirks = type({ + // When true, emit `reasoning_content` on every assistant message even when + // the turn carried no thinking (kimi requires it whenever thinking is + // enabled). Defaults to false: the field is emitted only on turns that + // actually have thinking. + "forceAssistantReasoningContent?": "boolean", + // Which delta fields to read reasoning tokens from, in precedence order. + // Constrained to the fields the chunk schema declares so the type cannot + // promise a field the parser would drop before reading. + "reasoningFieldNames?": "('reasoning_content' | 'reasoning')[]", + // Which field carries the output-token cap. First-party OpenAI gpt-5.x + // rejects `max_tokens` and requires `max_completion_tokens`; relays served + // through the same adapter (e.g. OpenCode Zen) still take `max_tokens`. + // Defaults to `max_tokens` so every existing deployment is unchanged. + "maxTokensField?": "'max_tokens' | 'max_completion_tokens'", + // Reject unknown keys so a mistyped quirk name fails loudly at construction + // rather than being silently ignored and running with default behavior. + "+": "reject", +}); +export type OpenAIQuirks = typeof OpenAIQuirks.infer; + +type ReasoningField = "reasoning_content" | "reasoning"; + +const DEFAULT_REASONING_FIELDS: readonly ReasoningField[] = [ + "reasoning_content", + "reasoning", +]; + +// Quirks resolved to concrete values at the factory edge, so interior code +// never re-decides a default. +type ResolvedOpenAIQuirks = { + forceAssistantReasoningContent: boolean; + reasoningFieldNames: readonly ReasoningField[]; + maxTokensField: "max_tokens" | "max_completion_tokens"; +}; + // --------------------------------------------------------------------------- // Request building // --------------------------------------------------------------------------- @@ -34,12 +75,15 @@ function buildRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, + quirks: ResolvedOpenAIQuirks, ): BuiltRequest { - const convertedMessages: unknown[] = messages.flatMap(toOpenAIMessage); + const convertedMessages: unknown[] = messages.flatMap((msg) => + toOpenAIMessage(msg, quirks.forceAssistantReasoningContent), + ); const body: Record = { model, - max_tokens: options.maxTokens ?? 4096, + [quirks.maxTokensField]: options.maxTokens ?? 4096, messages: convertedMessages, stream: true, }; @@ -57,6 +101,17 @@ function buildRequest( parameters: t.inputSchema, }, })); + // gpt-5.6 Chat Completions rejects function tools unless + // reasoning_effort is explicitly "none" (reasoned tool use is on + // the Responses API). Keep this list aligned with the discovery + // protocol builder's TOOL_CALL_REASONING_NONE_MODELS set. + if ( + model === "gpt-5.6-sol" || + model === "gpt-5.6-terra" || + model === "gpt-5.6-luna" + ) { + body["reasoning_effort"] = "none"; + } } if (options.systemPrompt) { @@ -108,7 +163,10 @@ function toOpenAIResponseFormat( } } -function toOpenAIMessage(msg: ConversationTurn): unknown[] { +function toOpenAIMessage( + msg: ConversationTurn, + forceAssistantReasoningContent: boolean, +): unknown[] { if (msg.role === "system") { const text = msg.content .filter((b): b is { type: "text"; text: string } => b.type === "text") @@ -145,7 +203,17 @@ function toOpenAIMessage(msg: ConversationTurn): unknown[] { if (parts.every((p) => typeof p === "string")) { return [{ role: "user", content: parts.join("") }]; } - return [{ role: "user", content: parts }]; + // Multimodal messages must use typed content parts. Bare strings next + // to image_url / file parts are not the Chat Completions wire shape + // (live vision and document captures use { type: "text", text }). + return [ + { + role: "user", + content: parts.map((p) => + typeof p === "string" ? { type: "text", text: p } : p, + ), + }, + ]; } if (msg.role === "assistant") { @@ -172,6 +240,10 @@ function toOpenAIMessage(msg: ConversationTurn): unknown[] { const textBlocks = msg.content.filter( (b): b is { type: "text"; text: string } => b.type === "text", ); + const safetyBlocks = msg.content.filter( + (b): b is Extract => + b.type === "safety_rating", + ); const thinkingBlocks = msg.content.filter( (b): b is { type: "thinking"; thinking: string } => b.type === "thinking", ); @@ -180,22 +252,41 @@ function toOpenAIMessage(msg: ConversationTurn): unknown[] { b.type === "tool_call", ); + // safety_rating-only assistant turns become a textual content + // string so the turn is not a hollow `{content: null}` message + // that confuses multi-turn Chat Completions history. + const textContent = [ + ...textBlocks.map((b) => b.text), + ...safetyBlocks.map((b) => formatSafetyRatingText(b)), + ].join(""); + + // Skip empty assistant turns that only carried dropped metadata. + if ( + textContent.length === 0 && + toolCalls.length === 0 && + thinkingBlocks.length === 0 + ) { + return []; + } + const result: Record = { role: "assistant" }; - if (textBlocks.length > 0) { - result["content"] = textBlocks.map((b) => b.text).join(""); + if (textContent.length > 0) { + result["content"] = textContent; } else { result["content"] = null; } - // Some providers (e.g. kimi) require reasoning_content on ALL assistant - // messages when thinking is enabled. If thinking blocks exist anywhere in - // the conversation, every assistant message must carry reasoning_content — - // even if empty for that particular turn. - result["reasoning_content"] = - thinkingBlocks.length > 0 - ? thinkingBlocks.map((b) => b.thinking).join("") - : ""; + // kimi requires reasoning_content on every assistant message once thinking + // is enabled anywhere in the conversation, even on turns that carried no + // thinking of their own. A source serving such a backend sets + // forceAssistantReasoningContent true, which keeps the field always + // present, empty on a turn with no thinking. The default is false: the + // field is emitted only on turns that actually have thinking. + const reasoning = thinkingBlocks.map((b) => b.thinking).join(""); + if (forceAssistantReasoningContent || thinkingBlocks.length > 0) { + result["reasoning_content"] = reasoning; + } if (toolCalls.length > 0) { result["tool_calls"] = toolCalls.map((tc) => ({ @@ -214,6 +305,14 @@ function toOpenAIMessage(msg: ConversationTurn): unknown[] { return [{ role: msg.role, content: "" }]; } +function filenameForDocumentMime(mimeType: string): string { + if (mimeType === "application/pdf") return "document.pdf"; + throw new Error( + `OpenAI Chat Completions document input currently supports ` + + `application/pdf only; received mimeType: ${mimeType}`, + ); +} + function toOpenAIContentPart(block: ContentBlock): unknown { switch (block.type) { case "text": @@ -266,20 +365,41 @@ function toOpenAIContentPart(block: ContentBlock): unknown { throw new Error( `OpenAI adapter does not yet handle ${block.type} content blocks.`, ); - case "document": - // OpenAI's Chat Completions added a `file` content type with - // `file_data`/`file_id` for PDF inputs, but the exact field - // names and required metadata (filename, content disposition) - // are version-sensitive and the OpenCode-Zen capture corpus - // carries no OpenAI document-input fixtures to ground-truth - // against. Surface the failure with explicit context rather - // than emitting an unverified wire shape that may 400 or — worse - // — silently land as malformed input the model ignores. - throw new Error( - "OpenAI adapter does not yet emit document content blocks; the " + - "Chat Completions file-content-type wire shape needs a captured " + - "fixture before the adapter can be wired against it.", - ); + case "document": { + // Grounded on packages/inference-discovery-openai/sessions/openai/ + // gpt-5.5/document-input/exchanges/0: Chat Completions takes + // { type: "file", file: { filename, file_data } } with file_data + // as a data URI. MediaSource has no filename field, so base64 + // inputs synthesize a deterministic name from mimeType. + const source = block.source; + if (source.kind === "base64") { + return { + type: "file", + file: { + filename: filenameForDocumentMime(source.mimeType), + file_data: `data:${source.mimeType};base64,${source.data}`, + }, + }; + } + if (source.kind === "file-reference") { + // Only meaningful when `reference` is an OpenAI Files API + // file_id. Handles minted by other providers will 400; that + // is correct — the adapter does not translate across providers. + return { + type: "file", + file: { file_id: source.reference }, + }; + } + if (source.kind === "url") { + throw new Error( + `OpenAI Chat Completions does not accept url document sources; ` + + `the file content type only takes base64 data URIs (file_data) ` + + `or uploaded file_id handles. Received url: ${source.url}`, + ); + } + source satisfies never; + throw new Error(`unreachable: unknown MediaSource kind`); + } case "citation": // Citation blocks are server-emitted attribution metadata for // content the model already produced; they're not part of the @@ -292,6 +412,13 @@ function toOpenAIContentPart(block: ContentBlock): unknown { // directly. See INFERENCE.md § Cross-Provider Message // Transformation for the general policy on history-drop fields. return ""; + case "safety_rating": + // Assistant history rewrites safety_rating via + // formatSafetyRatingText before this multimodal path. A + // safety_rating on a user multimodal turn has no input wire + // shape; return empty rather than throw so mixed user content + // can still marshal (same silent skip as citation). + return ""; case "code_execution_request": case "code_execution_result": // Code execution blocks are first-class semantic content; silently @@ -438,10 +565,26 @@ function getOrAssignToolCallIndex( return assigned; } +// Maps OpenAI's wire usage object onto the internal TokenUsage, reading the +// cached-token and reasoning-token detail sub-objects. Shared by both +// streaming usage branches (usage on a choices-empty chunk and usage riding a +// choice-bearing chunk) and the non-streaming parseJSONResponse, whose usage +// objects carry the same field names. +function toInferenceUsage(usage: typeof OpenAIChunkUsage.infer): TokenUsage { + return { + input: usage.prompt_tokens ?? 0, + output: usage.completion_tokens ?? 0, + cacheRead: usage.prompt_tokens_details?.cached_tokens ?? 0, + cacheWrite: 0, + thinking: usage.completion_tokens_details?.reasoning_tokens ?? 0, + }; +} + function parseResponse( sseData: string, indexer: OpenAIBlockIndexer, source: LastCycleSource, + reasoningFieldNames: readonly ReasoningField[], ): InferenceEvent[] { // parseSSE strips the `[DONE]` sentinel before yielding payloads, so // anything that reaches us here is supposed to be a JSON chunk. A @@ -479,15 +622,12 @@ function parseResponse( // Check for usage-only events (some providers send a final event with usage). const { usage } = chunk; if (usage != null) { - const tokenUsage: TokenUsage = { - input: usage.prompt_tokens ?? 0, - output: usage.completion_tokens ?? 0, - cacheRead: usage.prompt_tokens_details?.cached_tokens ?? 0, - cacheWrite: 0, - thinking: usage.completion_tokens_details?.reasoning_tokens ?? 0, - }; return [ - { type: "inference.usage", seq, data: { usage: tokenUsage, source } }, + { + type: "inference.usage", + seq, + data: { usage: toInferenceUsage(usage), source }, + }, ]; } return []; @@ -500,19 +640,30 @@ function parseResponse( const events: InferenceEvent[] = []; // Providers stream reasoning tokens under different field names: - // - kimi (via OpenRouter): delta.reasoning - // - kimi (direct): delta.reasoning_content - // - DeepSeek / others: delta.reasoning_content + // reasoning_content (kimi direct, DeepSeek) or reasoning (kimi via + // OpenRouter). `reasoningFieldNames` gives the fields to read and their + // precedence; the first field carrying a non-null value wins. An + // empty-string value still claims its slot (matching the prior + // `reasoning_content ?? reasoning` short-circuit) and is filtered by the + // length gate below. // - // OpenAI's Chat Completions ships reasoning_content and content as - // separate logical content blocks without a wire-level block index. - // The parser assigns indices on first observation in arrival order - // via the per-request `indexer`: whichever kind streams first lands - // at 0, the other (if it appears) at 1. This satisfies the harness's - // per-index routing contract — distinct kinds get distinct indices - // and the harness's collision detection between block kinds at the - // same index never fires from a normal OpenAI response. - const reasoning = delta.reasoning_content ?? delta.reasoning; + // OpenAI's Chat Completions ships reasoning and content as separate + // logical content blocks without a wire-level block index. The parser + // assigns indices on first observation in arrival order via the + // per-request `indexer`: whichever kind streams first lands at 0, the + // other (if it appears) at 1. This satisfies the harness's per-index + // routing contract — distinct kinds get distinct indices and the + // harness's collision detection between block kinds at the same index + // never fires from a normal OpenAI response. + let reasoning: string | null | undefined; + for (const field of reasoningFieldNames) { + const value = + field === "reasoning_content" ? delta.reasoning_content : delta.reasoning; + if (value !== undefined && value !== null) { + reasoning = value; + break; + } + } if (typeof reasoning === "string" && reasoning.length > 0) { events.push({ type: "inference.thinking.delta", @@ -643,20 +794,217 @@ function parseResponse( // Usage at end of stream (stream_options: { include_usage: true }). const usageInChunk = chunk.usage; if (usageInChunk != null) { - const tokenUsage: TokenUsage = { - input: usageInChunk.prompt_tokens ?? 0, - output: usageInChunk.completion_tokens ?? 0, - cacheRead: 0, - cacheWrite: 0, - thinking: 0, - }; events.push({ type: "inference.usage", seq, - data: { usage: tokenUsage, source }, + data: { usage: toInferenceUsage(usageInChunk), source }, + }); + } + + return events; +} + +// --------------------------------------------------------------------------- +// Non-streaming response parsing +// +// The non-streaming Chat Completions endpoint returns the whole assistant +// message in one JSON body. parseJSONResponse re-expresses it as the same +// InferenceEvent vocabulary parseResponse emits from the stream, so a +// replayed non-streaming capture feeds the harness accumulator identically to +// its streaming sibling. See parseResponse for the streaming counterpart. +// --------------------------------------------------------------------------- + +// A complete non-streaming tool call carries its id, type, and function name +// and arguments in full — unlike a streaming delta, where these arrive +// incrementally and are optional per chunk. Require them: a complete body +// missing them is malformed and should fail loudly at the boundary rather +// than decode into a tool call with a synthesized id or empty name. +const NonStreamingToolCall = type({ + "index?": "number", + id: "string", + type: "string", + function: { + name: "string", + arguments: "string", + }, +}); + +const NonStreamingMessage = type({ + "role?": "string", + "content?": "string | null", + "reasoning_content?": "string | null", + "reasoning?": "string | null", + "refusal?": "string | null", + "tool_calls?": NonStreamingToolCall.array(), +}); + +const NonStreamingCompletion = type({ + object: "'chat.completion'", + choices: type({ + "index?": "number", + message: NonStreamingMessage, + "finish_reason?": "string | null", + }).array(), + usage: OpenAIChunkUsage, +}); + +function parseJSONResponse( + body: string, + source: LastCycleSource, + reasoningFieldNames: readonly ReasoningField[], +): InferenceEvent[] { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new ProtocolMismatchError( + `openai parseJSONResponse: malformed JSON response body: ${message}`, + body, + ); + } + + const completion = NonStreamingCompletion(parsed); + if (completion instanceof type.errors) { + throw new ProtocolMismatchError( + `openai parseJSONResponse: response failed schema validation: ${completion.summary}`, + parsed, + ); + } + + const seq = 0; + + const choice = completion.choices[0]; + if (choice === undefined) { + // No choices: emit only usage, mirroring a usage-only streaming chunk. + return [ + { + type: "inference.usage", + seq, + data: { usage: toInferenceUsage(completion.usage), source }, + }, + ]; + } + const { message } = choice; + + // A fresh indexer per body. Content-block indices are synthesized on first + // observation, so this must not share the adapter-instance counter the + // streaming parser advances. + const indexer: OpenAIBlockIndexer = { + nextIndex: 0, + textIndex: null, + thinkingIndex: null, + refusalIndex: null, + toolCallBlockIndex: new Map(), + }; + + const events: InferenceEvent[] = []; + + // Walk the message fields in the SAME order the streaming parser processes a + // delta chunk (reasoning -> content -> refusal -> tool_calls) through the + // same getOrAssign* helpers. For OpenAI this reproduces the streaming + // arrival-order index assignment: reasoning models flush reasoning before + // answer text, refusal is exclusive with content, and text/thinking/refusal + // each collapse to a single cached slot — so a complete message's field + // order matches the order the stream would have assigned indices. Empty + // fields must NOT claim an index (every getOrAssign call stays behind a + // non-empty gate, as on the streaming path), or the decoded turn would carry + // a phantom block the stream never produced. + let reasoning: string | null | undefined; + for (const field of reasoningFieldNames) { + const value = + field === "reasoning_content" + ? message.reasoning_content + : message.reasoning; + if (value !== undefined && value !== null) { + reasoning = value; + break; + } + } + if (typeof reasoning === "string" && reasoning.length > 0) { + events.push({ + type: "inference.thinking.delta", + seq, + data: { + token: reasoning, + partial: EMPTY_PARTIAL, + index: getOrAssignThinkingIndex(indexer), + }, }); } + const { content } = message; + if (typeof content === "string" && content.length > 0) { + events.push({ + type: "inference.text.delta", + seq, + data: { + token: content, + partial: EMPTY_PARTIAL, + index: getOrAssignTextIndex(indexer), + }, + }); + } + + const { refusal } = message; + if (typeof refusal === "string" && refusal.length > 0) { + events.push({ + type: "inference.refusal.delta", + seq, + data: { + token: refusal, + partial: EMPTY_PARTIAL, + index: getOrAssignRefusalIndex(indexer), + }, + }); + } + + for (const [position, toolCall] of (message.tool_calls ?? []).entries()) { + // Genuine OpenAI non-streaming responses omit `index` on tool_calls[] + // (only the streaming deltas carry it, and the opencode-zen backends + // include it on the array too). Key the block-index slot on the array + // position when the wire index is absent, so parallel tool calls get + // distinct slots instead of all collapsing onto slot 0 and colliding in + // the harness's per-index accumulator. + const blockIndex = getOrAssignToolCallIndex( + indexer, + toolCall.index ?? position, + ); + // Mirror the streaming convention exactly: the start carries the real id + // and the block index; the args delta carries String(blockIndex) as its + // callId placeholder, which the harness resolves via the indexToCallId + // mapping it registers from the start event's index. Start must precede + // the delta, or the harness silently drops the fragment. + events.push({ + type: "inference.tool_call.start", + seq, + data: { + callId: toolCall.id, + name: decodeToolName(toolCall.function.name), + partial: EMPTY_PARTIAL, + index: blockIndex, + }, + }); + if (toolCall.function.arguments.length > 0) { + events.push({ + type: "inference.tool_call.delta", + seq, + data: { + callId: String(blockIndex), + argumentFragment: toolCall.function.arguments, + partial: EMPTY_PARTIAL, + index: blockIndex, + }, + }); + } + } + + events.push({ + type: "inference.usage", + seq, + data: { usage: toInferenceUsage(completion.usage), source }, + }); + return events; } @@ -713,7 +1061,22 @@ function parseDuration(value: string): number | undefined { return total > 0 ? Math.ceil(total) : undefined; } -export function createOpenAIAdapter(source: LastCycleSource): ProviderAdapter { +export function createOpenAIAdapter( + source: LastCycleSource, + quirks?: unknown, +): ProviderAdapter { + const parsedQuirks = OpenAIQuirks(quirks ?? {}); + if (parsedQuirks instanceof type.errors) { + throw new Error(`openai adapter: invalid quirks: ${parsedQuirks.summary}`); + } + const resolvedQuirks: ResolvedOpenAIQuirks = { + forceAssistantReasoningContent: + parsedQuirks.forceAssistantReasoningContent ?? false, + reasoningFieldNames: + parsedQuirks.reasoningFieldNames ?? DEFAULT_REASONING_FIELDS, + maxTokensField: parsedQuirks.maxTokensField ?? "max_tokens", + }; + // Per-request indexer state. Adapter instances are created per // request (see `adapter.ts`), so each call to `createOpenAIAdapter` // gets a fresh counter for assigning block indices to reasoning vs. @@ -726,8 +1089,17 @@ export function createOpenAIAdapter(source: LastCycleSource): ProviderAdapter { toolCallBlockIndex: new Map(), }; return { - buildRequest, - parseResponse: (sseData) => parseResponse(sseData, indexer, source), + buildRequest: (messages, model, options) => + buildRequest(messages, model, options, resolvedQuirks), + parseResponse: (sseData) => + parseResponse( + sseData, + indexer, + source, + resolvedQuirks.reasoningFieldNames, + ), + parseJSONResponse: (body) => + parseJSONResponse(body, source, resolvedQuirks.reasoningFieldNames), extractRetryAfterMs, extractPacingDelayMs, }; diff --git a/vendor/intx-inference/src/reactor-tool-checkpoint.test.ts b/vendor/intx-inference/src/reactor-tool-checkpoint.test.ts deleted file mode 100644 index bd97d2395..000000000 --- a/vendor/intx-inference/src/reactor-tool-checkpoint.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { describe, test, expect } from "bun:test"; - -import { createReactor } from "./reactor"; -import { createDefaultScheduler } from "./harness"; -import type { Dependencies } from "./harness"; -import type { ReactorEmittedEvent } from "./reactor"; -import type { - AssistantTurn, - ContextStore, - ContextCommit, - ConversationTurn, - InboundMessage, - InferenceEvent, - ReactorAction, - ReactorCapabilities, - ReactorDirector, - ReactorInboundEvent, - ReactorState, - TokenUsage, - ToolRunner, - LastCycleSource, -} from "@intx/types/runtime"; - -// Regression guard: the reactor committed context only at cycle -// terminals (wait/reply/done/suspend). A tool-call turn continued to the next -// inference without committing, so an interrupt that rebuilt the agent from the -// persisted store between the tool batch and the follow-up inference erased the -// assistant tool_call turn and its tool results. The reactor must checkpoint -// after each completed tool cycle (inference + tool results). - -function emptyUsage(): TokenUsage { - return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }; -} - -const TEST_SOURCE: LastCycleSource = { - sourceId: "test-source", - provider: "test-provider", - model: "test-model", -}; - -function stubDeps(): Dependencies { - // The reactor drives inference through the injected `inferenceRunner`, so the - // HTTP path is never touched — these are inert placeholders satisfying the - // required shape. - return { - fetch: () => Promise.reject(new Error("fetch must not be called")), - scheduler: createDefaultScheduler(), - adapters: { - resolve() { - throw new Error("adapters.resolve must not be called"); - }, - }, - }; -} - -function inboundMessage(): InboundMessage { - return { - ref: { uid: 0, mailbox: "inbox" }, - headers: { - from: "user@local", - to: ["agent@local"], - date: new Date(0).toISOString(), - messageId: "msg-1@local", - }, - flags: [], - content: "do the thing", - signatureStatus: "missing", - }; -} - -function assistantToolCallTurn(callId: string, name: string): AssistantTurn { - return { - role: "assistant", - content: [{ type: "tool_call", id: callId, name, arguments: {} }], - model: "mock-model", - timestamp: 1000, - }; -} - -function assistantTextTurn(text: string): AssistantTurn { - return { - role: "assistant", - content: [{ type: "text", text }], - model: "mock-model", - timestamp: 1000, - }; -} - -function noopToolRunner(): ToolRunner { - return { - async run(call) { - return { callId: call.id, content: "ok" }; - }, - }; -} - -// Records a deep snapshot of history at every commit so we can assert the tool -// cycle was made durable before the follow-up inference ran. -function capturingContextStore(commits: ConversationTurn[][]): ContextStore { - let latestTurns: ConversationTurn[] = []; - const notImplemented = () => { - throw new Error("not implemented"); - }; - return { - async load() { - return { - turns: [], - pendingOperations: [], - tokenUsage: emptyUsage(), - connectorState: null, - }; - }, - setConnectorState() { - /* noop */ - }, - async commit(options: { message: string }): Promise { - commits.push([...latestTurns]); - return { hash: "abc", message: options.message, timestamp: Date.now() }; - }, - async branch() { - /* noop */ - }, - async log() { - return []; - }, - async readAt() { - return []; - }, - async writeBlob() { - /* noop */ - }, - async readBlob() { - return notImplemented(); - }, - async writePrompt() { - /* noop */ - }, - async writeResponse() { - /* noop */ - }, - async writeManifest() { - /* noop */ - }, - async writeTurns(turns: ConversationTurn[]) { - latestTurns = [...turns]; - }, - async writeMetadata() { - /* noop */ - }, - async readManifestHistory() { - return notImplemented(); - }, - }; -} - -// Mirrors the real conversational flow: infer, run the emitted tool calls, then -// re-infer with the results, then reply-terminal. The tool-execution and -// tool-done edges each carry a checkpoint, exactly like DefaultDirector. -function conversationalToolDirector(): ReactorDirector { - return { - async decide( - event: ReactorInboundEvent, - _state: ReactorState, - caps: ReactorCapabilities, - ): Promise { - switch (event.type) { - case "message.received": - return caps.infer(); - case "inference.done": { - const hasToolCall = event.turn.content.some( - (b) => b.type === "tool_call", - ); - if (hasToolCall) { - return [ - caps.checkpoint("tool-execution"), - caps.executeTools([{ id: "c1", name: "probe", arguments: {} }], true), - ]; - } - return [caps.checkpoint("final"), caps.done()]; - } - case "tool.done": - return [caps.checkpoint("tool-done"), caps.infer()]; - default: - return caps.done(); - } - }, - }; -} - -describe("createReactor — mid-cycle tool checkpoint durability", () => { - test("commits the inference + tool results before the next inference", async () => { - const commits: ConversationTurn[][] = []; - let inferenceCount = 0; - - const events: ReactorEmittedEvent[] = []; - const reactor = createReactor({ - sessionId: "test-checkpoint", - director: conversationalToolDirector(), - source: { - id: "test:model", - provider: "test", - baseURL: "https://example.test", - apiKey: "test", - model: "model", - }, - toolRunner: noopToolRunner(), - contextStore: capturingContextStore(commits), - onEvent: (e) => events.push(e), - deps: stubDeps(), - shutdownTimeoutMs: 100, - inferenceRunner: async function* (opts) { - inferenceCount += 1; - const turn = - inferenceCount === 1 - ? assistantToolCallTurn("c1", "probe") - : assistantTextTurn("all done"); - yield { - type: "inference.done", - seq: opts.nextSeq(), - data: { turn, usage: emptyUsage(), source: TEST_SOURCE }, - } satisfies InferenceEvent; - }, - }); - - reactor.start(); - reactor.deliver(inboundMessage()); - - await new Promise((resolve, reject) => { - const deadline = setTimeout( - () => reject(new Error("timed out waiting for reactor.done")), - 2000, - ); - const check = () => { - if (events.some((e) => e.type === "reactor.done")) { - clearTimeout(deadline); - resolve(); - return; - } - setTimeout(check, 10); - }; - check(); - }); - - const lastTurnHasToolResult = (turns: ConversationTurn[]): boolean => { - const last = turns[turns.length - 1]; - return ( - last !== undefined && last.content.some((b) => b.type === "tool_result") - ); - }; - - // Some commit captured the tool cycle with the tool_result as its final - // turn — the exchange was durable before the second inference. - const toolCycleCommit = commits.find(lastTurnHasToolResult); - expect(toolCycleCommit).toBeDefined(); - if (toolCycleCommit === undefined) return; - - // That durable snapshot includes the assistant tool_call turn the result - // answers, so a rebuild reloads a well-formed exchange. - const hasAssistantToolCall = toolCycleCommit.some( - (t) => - t.role === "assistant" && t.content.some((b) => b.type === "tool_call"), - ); - expect(hasAssistantToolCall).toBe(true); - - // Both inferences ran, so the durable tool-cycle snapshot landed mid-cycle - // rather than only at the terminal. - expect(inferenceCount).toBe(2); - }); - - test("skips mid-cycle commit when addToHistory is false", async () => { - const commits: ConversationTurn[][] = []; - let inferenceCount = 0; - - const director: ReactorDirector = { - async decide(event, _state, caps) { - switch (event.type) { - case "message.received": - return caps.infer(); - case "inference.done": { - const hasToolCall = event.turn.content.some( - (b) => b.type === "tool_call", - ); - if (hasToolCall) { - return caps.executeTools( - [{ id: "c1", name: "probe", arguments: {} }], - true, - false, - ); - } - return caps.done(); - } - case "tool.done": - return caps.infer(); - default: - return caps.done(); - } - }, - }; - - const events: ReactorEmittedEvent[] = []; - const reactor = createReactor({ - sessionId: "test-checkpoint-no-history", - director, - source: { - id: "test:model", - provider: "test", - baseURL: "https://example.test", - apiKey: "test", - model: "model", - }, - toolRunner: noopToolRunner(), - contextStore: capturingContextStore(commits), - onEvent: (e) => events.push(e), - deps: stubDeps(), - shutdownTimeoutMs: 100, - inferenceRunner: async function* (opts) { - inferenceCount += 1; - const turn = - inferenceCount === 1 - ? assistantToolCallTurn("c1", "probe") - : assistantTextTurn("done"); - yield { - type: "inference.done", - seq: opts.nextSeq(), - data: { turn, usage: emptyUsage(), source: TEST_SOURCE }, - } satisfies InferenceEvent; - }, - }); - - reactor.start(); - reactor.deliver(inboundMessage()); - - await new Promise((resolve, reject) => { - const deadline = setTimeout( - () => reject(new Error("timed out waiting for reactor.done")), - 2000, - ); - const check = () => { - if (events.some((e) => e.type === "reactor.done")) { - clearTimeout(deadline); - resolve(); - return; - } - setTimeout(check, 10); - }; - check(); - }); - - const endsWithToolResult = (turns: ConversationTurn[]): boolean => { - const last = turns[turns.length - 1]; - return ( - last !== undefined && last.content.some((b) => b.type === "tool_result") - ); - }; - - expect(commits.every((snapshot) => !endsWithToolResult(snapshot))).toBe( - true, - ); - expect(inferenceCount).toBe(2); - }); -}); diff --git a/vendor/intx-inference/src/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 0688c80a3..9b02f48c5 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -1,11 +1,13 @@ import { describe, test, expect } from "bun:test"; import { validateActions } from "./actions"; -import { createStateManager } from "./state"; +import { createAuthzExtension } from "./authz-extension"; import { createGateManager } from "./gates"; import { createCorrelationRegistry } from "./correlation"; import { createReactor } from "./reactor"; import { createDefaultDependencies } from "./providers"; +import { createDefaultDirector } from "./default-director"; +import { assertWellFormedToolSequence } from "./turns"; import { createInboundMessage } from "@intx/mime"; import type { @@ -33,6 +35,7 @@ import type { import type { ReactorConfig, Reactor, ReactorEmittedEvent } from "./reactor"; import type { Dependencies, InferenceHarnessOptions } from "./harness"; import type { CorrelationValidator } from "./correlation"; +import type { AfterInferenceHook } from "./default-director"; // --------------------------------------------------------------------------- // Helpers @@ -178,6 +181,22 @@ function makeInboundMessage(correlationId?: string): InboundMessage { }); } +// An approval decision delivered to a parked run, stamped with the +// correlationId of the suspension it resolves. The body is the JSON-encoded +// ApprovalDecision the step invoker packs as the message content, which the +// reactor parses on the correlation path to drive the resume. +function makeApprovalMessage( + correlationId: string, + outcome: "approved" | "rejected" = "approved", +): InboundMessage { + return createInboundMessage({ + from: "signal@local", + to: "agent@example.com", + content: JSON.stringify({ outcome }), + correlationId, + }); +} + // --------------------------------------------------------------------------- // Test harness helpers // --------------------------------------------------------------------------- @@ -687,6 +706,32 @@ describe("validateActions", () => { if (result.ok) throw new Error("unreachable"); expect(result.error).toMatch(/reply.*suspend/i); }); + + // The director builds action sets with a leading checkpoint. The + // reply+done and wait+reply invariants must hold for that emitted + // three-action shape, not only the bare pairs above, so a checkpoint + // prefix cannot smuggle a contradictory set past the validator. + test("checkpoint + reply + done is invalid", () => { + const result = validateActions([ + { type: "checkpoint", message: "checkpoint: after-inference-abort" }, + { type: "reply", content: "budget exhausted" }, + { type: "done" }, + ]); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toMatch(/reply.*done/i); + }); + + test("checkpoint + reply + wait is invalid", () => { + const result = validateActions([ + { type: "checkpoint", message: "checkpoint: after-inference-halt" }, + { type: "reply", content: "paused for top-up" }, + { type: "wait" }, + ]); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toMatch(/wait.*reply/i); + }); }); // --------------------------------------------------------------------------- @@ -714,6 +759,37 @@ describe("createGateManager", () => { expect(cleared).toEqual(["gate-1:resolved"]); }); + test("clearSilently resolves and removes the gate without invoking onCleared", async () => { + const manager = createGateManager(); + const cleared: string[] = []; + + const promise = manager.register( + "gate-silent", + "approval", + 5000, + undefined, + (id, reason) => cleared.push(`${id}:${reason}`), + ); + + const didClear = manager.clearSilently("gate-silent"); + expect(didClear).toBe(true); + + // The gate's promise still resolves as resolved, so any awaiter unblocks. + const reason = await promise; + expect(reason).toBe("resolved"); + + // The whole point of clearSilently: onCleared is NOT invoked, so no + // gate-cleared continuation is enqueued (contrast with clear() above, + // which does invoke it). + expect(cleared).toEqual([]); + + // The gate is gone: it no longer resolves by correlation and a second + // clear finds nothing to clear. + expect(manager.has("gate-silent")).toBe(false); + expect(manager.clear("gate-silent")).toBe(false); + expect(manager.clearSilently("gate-silent")).toBe(false); + }); + test("gate timeout fires with reason=timeout", async () => { const manager = createGateManager(); const cleared: string[] = []; @@ -774,7 +850,7 @@ describe("createGateManager", () => { test("duplicate gate ID throws", () => { const manager = createGateManager(); - manager.register("g", "approval", 5000, undefined, () => { + void manager.register("g", "approval", 5000, undefined, () => { /* noop */ }); expect(() => @@ -786,7 +862,7 @@ describe("createGateManager", () => { test("findByCorrelationId returns the gate", () => { const manager = createGateManager(); - manager.register("g-corr", "message_response", 5000, "corr-42", () => { + void manager.register("g-corr", "message_response", 5000, "corr-42", () => { /* noop */ }); const found = manager.findByCorrelationId("corr-42"); @@ -803,7 +879,12 @@ describe("createGateManager", () => { describe("createCorrelationRegistry", () => { function op(id: string): PendingOperation { - return { correlationId: id, registeredAt: Date.now(), gateId: `g-${id}` }; + return { + correlationId: id, + kind: "approval", + registeredAt: Date.now(), + gateId: `g-${id}`, + }; } test("register and lookup", () => { @@ -979,6 +1060,115 @@ describe("createReactor — gate lifecycle", () => { expect(cleared.data.reason).toBe("shutdown"); }); + test("does not emit reactor.gate.blocked until the suspend is durably committed", async () => { + // Persist-before-settle. The `reactor.gate.blocked` event resolves the + // `send()` awaiter as "suspended", and a downstream consumer (the warm + // agent's run-boundary durability mirror) reads the pending operation back + // out of the context store the instant `send()` settles. So the durable + // commit must land BEFORE the event is emitted -- otherwise the mirror + // reads an uncommitted store and durably loses the approval snapshot. This + // test gates the commit's `writeMetadata`: `blocked` must not appear while + // the commit is pending, and must appear once it is released. A regression + // that emits before committing would fire `blocked` while the gate is held. + let releaseCommit: (() => void) | undefined; + const commitGate = new Promise((resolve) => { + releaseCommit = resolve; + }); + const base = makeContextStore(); + const contextStore: ContextStore = { + ...base, + async writeMetadata(metadata, signal) { + await commitGate; + return base.writeMetadata(metadata, signal); + }, + }; + const { reactor, events, waitFor } = createTestReactor({ + contextStore, + director: directorFromTable({ + "message.received": (_e, _s, caps) => + caps.suspend({ + type: "approval", + gateId: "commit-order-gate", + timeoutMs: 5000, + }), + "reactor.gate.cleared": (_e, _s, caps) => caps.done(), + }), + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + // The commit is held, so a correctly-ordered reactor has not emitted + // `blocked` yet; a reactor that emits before committing would have. + await new Promise((r) => setTimeout(r, 50)); + expect(events.some((e) => e.type === "reactor.gate.blocked")).toBe(false); + + // Release the durable commit; only now may `blocked` be emitted. + releaseCommit?.(); + await waitFor("reactor.gate.blocked"); + + reactor.abort("admin_kill"); + await waitFor("reactor.done"); + }); + + test("defers a gate clear racing the commit until after blocked", async () => { + // blocked-before-cleared. A gate whose timeout timer elapses while the + // suspend's durable commit is still in flight must not have its clear take + // effect before `reactor.gate.blocked` is emitted: downstream status + // derivation and the send-awaiter assume a gate's `blocked` precedes any + // effect of its clearing. This holds the commit's `writeMetadata` open long + // enough for a short gate timeout to fire inside the window, then asserts + // `blocked` is emitted before the `reactor.gate.cleared` it belongs to. + let releaseCommit: (() => void) | undefined; + const commitGate = new Promise((resolve) => { + releaseCommit = resolve; + }); + const base = makeContextStore(); + const contextStore: ContextStore = { + ...base, + async writeMetadata(metadata, signal) { + await commitGate; + return base.writeMetadata(metadata, signal); + }, + }; + const { reactor, events, waitFor } = createTestReactor({ + contextStore, + director: directorFromTable({ + "message.received": (_e, _s, caps) => + caps.suspend({ + type: "approval", + gateId: "race-gate", + timeoutMs: 30, + }), + "reactor.gate.cleared": (_e, _s, caps) => caps.done(), + }), + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + // Let the gate's timeout elapse while the commit is still held. Neither the + // block nor the clear may surface until the commit is released. + await new Promise((r) => setTimeout(r, 80)); + expect(events.some((e) => e.type === "reactor.gate.blocked")).toBe(false); + expect(events.some((e) => e.type === "reactor.gate.cleared")).toBe(false); + + releaseCommit?.(); + await waitFor("reactor.done"); + + const blockedIndex = events.findIndex( + (e) => e.type === "reactor.gate.blocked", + ); + const clearedIndex = events.findIndex( + (e) => e.type === "reactor.gate.cleared", + ); + expect(blockedIndex).toBeGreaterThanOrEqual(0); + expect(clearedIndex).toBeGreaterThan(blockedIndex); + expect(getEvent(events, "reactor.gate.cleared").data.reason).toBe( + "timeout", + ); + }); + test("gate timeout fires reactor.gate.cleared with reason=timeout", async () => { const { reactor, events, waitFor } = createTestReactor({ shutdownTimeoutMs: 500, @@ -1132,63 +1322,6 @@ describe("createReactor — correlation", () => { ); }); - test("the same correlationId can correlate again after a successful correlation", async () => { - // The in-flight guard set used to retain the id on the success path, so a - // second pending operation reusing the same correlationId could never - // correlate. Two full round trips must both reach message.correlated. - const CORR_ID = "corr-reuse-1"; - let phase = 0; - const director: ReactorDirector = { - async decide(event, _state, caps) { - if (event.type === "message.received" && phase === 0) { - phase = 1; - return caps.executeTools([{ id: "tc1", name: "send_message", arguments: {} }]); - } - if (event.type === "tool.done") { - return caps.suspend({ - type: "message_response", - gateId: `msg-gate-${phase}`, - timeoutMs: 5000, - correlationId: CORR_ID, - }); - } - if (event.type === "reactor.gate.cleared" && phase === 1) { - phase = 2; - return caps.executeTools([{ id: "tc2", name: "send_message", arguments: {} }]); - } - return caps.done(); - }, - }; - - const { reactor, events, waitFor } = createTestReactor({ - director, - shutdownTimeoutMs: 500, - toolRunner: makeToolRunner(async (call) => ({ - callId: call.id, - content: "message sent", - pendingMarker: { status: "pending" as const, correlationId: CORR_ID }, - })), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - - await waitForEvent(events, (e) => e.type === "reactor.gate.blocked"); - reactor.deliver(makeInboundMessage(CORR_ID)); - - await waitForEvent( - events, - (e) => e.type === "reactor.gate.blocked" && e.data.gateId === "msg-gate-2", - 3000, - ); - reactor.deliver(makeInboundMessage(CORR_ID)); - - await waitFor("reactor.done", 3000); - - const correlations = events.filter((e) => e.type === "message.correlated"); - expect(correlations.length).toBe(2); - }); - test("message with non-matching correlationId passes through uncorrelated", async () => { const { reactor, events, waitFor } = createTestReactor({ director: directorFromTable({ @@ -2393,7 +2526,7 @@ describe("createReactor — state snapshot inspection", () => { const CORR_ID = "corr-snapshot-check"; let capturedOps: PendingOperation[] = []; - const { reactor, waitFor } = createTestReactor({ + const { reactor, events, waitFor } = createTestReactor({ director: directorFromTable({ "message.received": (_e, _s, caps) => caps.executeTools([{ id: "tc1", name: "send_msg", arguments: {} }]), @@ -2424,6 +2557,15 @@ describe("createReactor — state snapshot inspection", () => { const op = capturedOps[0]; if (op === undefined) throw new Error("unreachable"); expect(op.correlationId).toBe(CORR_ID); + // An async-tool pending marker is not an ask-rail suspension: it carries no + // suspended call and no approval snapshot. + expect(op.suspendedCall).toBeUndefined(); + expect(op.approvalSnapshot).toBeUndefined(); + // It emits no `reactor.gate.blocked` -- the event that drives the hub's + // approval co-write -- so a marker never writes an approval row. This keeps + // the snapshot columns non-null: only the ask rail co-writes, and it always + // carries a snapshot. + expect(events.some((e) => e.type === "reactor.gate.blocked")).toBe(false); }); test("state.activeGates reflects registered gates during suspend", async () => { @@ -2511,15 +2653,12 @@ describe("createReactor — state snapshot inspection", () => { if (event.type === "message.received") { messageCount++; if (messageCount === 1) { - // Turns are frozen, so mutating a snapshot block must throw - // rather than corrupt shared state. + // Mutate the snapshot's content block. const msg = state.turns[0]; if (msg !== undefined) { const block = msg.content[0]; if (block !== undefined && block.type === "text") { - expect(() => { - (block as { text: string }).text = "CORRUPTED"; - }).toThrow(); + (block as { text: string }).text = "CORRUPTED"; } } return caps.wait(); @@ -2620,6 +2759,98 @@ function makeInferenceRunner( }; } +// --------------------------------------------------------------------------- +// afterInferenceDone abort/halt policy, end to end +// +// These drive the real DefaultDirector so the action sets its abort and +// halt branches build are validated by the reactor, not just asserted in +// isolation. The bug was that those sets were rejected, so the reactor +// crashed with a fatal "Invalid action set" instead of terminating +// (abort) or pausing and replying (halt). +// --------------------------------------------------------------------------- + +describe("createReactor — afterInferenceDone abort and halt", () => { + test("abort terminates without an invalid action set", async () => { + const hook: AfterInferenceHook = () => ({ + type: "abort", + reason: "budget exhausted", + }); + const { reactor, events, waitFor } = createTestReactor({ + director: createDefaultDirector("test agent", [], { + afterInferenceDone: hook, + }), + inferenceRunner: makeInferenceRunner({ + type: "done", + turn: makeAssistantTurn("ignored"), + usage: emptyUsage(), + }), + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + await waitFor("reactor.done"); + + expect( + events.some( + (e) => + e.type === "reactor.error" && + /invalid action set/i.test(e.data.error), + ), + ).toBe(false); + // Abort is terminal and does not surface the reason, so no reply. + expect(events.some((e) => e.type === "connector.reply")).toBe(false); + }); + + test("halt replies and keeps the reactor alive", async () => { + let hookCalls = 0; + const hook: AfterInferenceHook = () => { + hookCalls++; + return { type: "halt", reason: "paused for top-up" }; + }; + const { reactor, events, waitFor } = createTestReactor({ + director: createDefaultDirector("test agent", [], { + afterInferenceDone: hook, + }), + inferenceRunner: makeInferenceRunner({ + type: "done", + turn: makeAssistantTurn("ignored"), + usage: emptyUsage(), + }), + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + await waitForEvent(events, (e) => e.type === "connector.reply"); + + // Halt returned the reactor to waiting rather than shutting down, so a + // second message must still be processed — that is the liveness proof. + reactor.deliver(makeInboundMessage()); + await waitForEvent( + events, + () => events.filter((e) => e.type === "connector.reply").length >= 2, + ); + + // The reactor is still alive; abort it so the test does not leak it. + reactor.abort("admin_kill"); + await waitFor("reactor.done"); + + const replies = events.filter((e) => e.type === "connector.reply"); + expect(replies.length).toBe(2); + expect(hookCalls).toBe(2); + for (const reply of replies) { + if (reply.type !== "connector.reply") throw new Error("unreachable"); + expect(reply.data.content).toBe("paused for top-up"); + } + expect( + events.some( + (e) => + e.type === "reactor.error" && + /invalid action set/i.test(e.data.error), + ), + ).toBe(false); + }); +}); + describe("createReactor — inference path", () => { test("infer action drives inference.done through to director and accumulates usage", async () => { const inferUsage: TokenUsage = { @@ -2794,7 +3025,7 @@ describe("createReactor — beforeToolExtensions", () => { test("allowing extension lets the tool run normally", async () => { const allowAll: BeforeToolExtension = { async beforeTool() { - return undefined; + return { type: "allow" }; }, }; @@ -2830,8 +3061,9 @@ describe("createReactor — beforeToolExtensions", () => { test("blocking extension prevents tool execution", async () => { const blockBash: BeforeToolExtension = { async beforeTool(call) { - if (call.name === "bash") return "Denied by policy"; - return undefined; + if (call.name === "bash") + return { type: "block", reason: "Denied by policy" }; + return { type: "allow" }; }, }; @@ -2884,14 +3116,14 @@ describe("createReactor — beforeToolExtensions", () => { const extA: BeforeToolExtension = { async beforeTool() { called.push("A"); - return "Blocked by A"; + return { type: "block", reason: "Blocked by A" }; }, }; const extB: BeforeToolExtension = { async beforeTool() { called.push("B"); - return undefined; + return { type: "allow" }; }, }; @@ -2973,7 +3205,7 @@ describe("createReactor — beforeToolExtensions", () => { const secondExt: BeforeToolExtension = { async beforeTool() { called.push("second"); - return undefined; + return { type: "allow" }; }, }; @@ -3004,7 +3236,7 @@ describe("createReactor — beforeToolExtensions", () => { const capturingExt: BeforeToolExtension = { async beforeTool(_call, state) { capturedState = state; - return undefined; + return { type: "allow" }; }, }; @@ -3036,8 +3268,9 @@ describe("createReactor — beforeToolExtensions", () => { test("parallel batch with one blocked and one allowed tool", async () => { const blockBash: BeforeToolExtension = { async beforeTool(call) { - if (call.name === "bash") return "Denied by policy"; - return undefined; + if (call.name === "bash") + return { type: "block", reason: "Denied by policy" }; + return { type: "allow" }; }, }; @@ -3140,33 +3373,1026 @@ describe("createReactor — beforeToolExtensions", () => { }); // --------------------------------------------------------------------------- -// Lifecycle hooks: afterCheckpoint and onShutdown +// Before-tool suspension on an `ask` grant, and rehydration across restart // --------------------------------------------------------------------------- -describe("createReactor — afterCheckpoint", () => { - test("afterCheckpoint is called after successful checkpoint", async () => { - let afterCheckpointCalled = false; +// A context store that keeps the persisted pending operations and turns in a +// shared cell so a second reactor can reload exactly what the first committed. +// This is what lets the rehydration test observe the cross-restart behavior a +// stateless makeContextStore cannot. +type PersistedCell = { + turns: ConversationTurn[]; + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; +}; - const { reactor, waitFor } = createTestReactor({ - director: directorFromTable({ - "message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()], +function makePersistingContextStore(cell: PersistedCell): ContextStore { + return { + async load() { + return { + turns: cell.turns, + pendingOperations: cell.pendingOperations, + tokenUsage: cell.tokenUsage, + connectorState: null, + }; + }, + setConnectorState() { + /* noop */ + }, + async commit(options: { message: string }) { + return { hash: "hash", message: options.message, timestamp: Date.now() }; + }, + async branch() { + /* noop */ + }, + async log() { + return []; + }, + async readAt() { + return []; + }, + async writeBlob() { + /* noop */ + }, + async readBlob() { + throw new Error("not implemented"); + }, + async writePrompt() { + /* noop */ + }, + async writeResponse() { + /* noop */ + }, + async writeManifest() { + /* noop */ + }, + async writeTurns(turns) { + cell.turns = turns; + }, + async writeMetadata(metadata) { + cell.pendingOperations = metadata.pendingOperations; + cell.tokenUsage = metadata.tokenUsage; + }, + async readManifestHistory() { + throw new Error("not implemented"); + }, + }; +} + +// A tool-call assistant turn used to drive the before-tool path. +const suspendToolCallTurn: AssistantTurn = { + role: "assistant", + content: [ + { type: "tool_call", id: "call-ask", name: "charge_card", arguments: {} }, + ], + model: "test-model", + timestamp: 1000, +}; + +describe("createReactor — before-tool suspension on ask grant", () => { + test("an ask grant suspends the call: gate.blocked carries the correlationId, no tool result, pending op persisted", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const askExtension = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, }), - afterCheckpoint: async () => { - afterCheckpointCalled = true; - }, + approvalTimeoutMs: 60_000, + }); + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: directorFromTable( + { + "message.received": (_e, _s, caps) => caps.infer(), + "inference.done": (_e, _s, caps) => + caps.executeTools([ + { id: "call-ask", name: "charge_card", arguments: {} }, + ]), + "reactor.gate.cleared": (_e, _s, caps) => caps.done(), + }, + "wait", + ), + inferenceRunner: makeInferenceRunner({ + type: "done", + turn: suspendToolCallTurn, + usage: emptyUsage(), + }), + beforeToolExtensions: [askExtension], }); reactor.start(); reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + expect(blocked.data.reason).toBe("approval"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) + throw new Error("expected reactor.gate.blocked to carry a correlationId"); + expect(blocked.data.gateId).toBe(`pending-${correlationId}`); + + // The suspended call is parked, not answered: no tool.start, no tool.done. + expect(events.some((e) => e.type === "tool.start")).toBe(false); + expect(events.some((e) => e.type === "tool.done")).toBe(false); + + // The pending operation was persisted with kind "approval", the minted + // correlationId, and its deadline. + expect(cell.pendingOperations).toHaveLength(1); + const op = cell.pendingOperations[0]; + if (op === undefined) throw new Error("unreachable"); + expect(op.kind).toBe("approval"); + expect(op.correlationId).toBe(correlationId); + expect(op.gateId).toBe(`pending-${correlationId}`); + expect(op.timeoutAt).toBeGreaterThan(Date.now()); + + reactor.abort("admin_kill"); await waitFor("reactor.done"); - expect(afterCheckpointCalled).toBe(true); }); - test("afterCheckpoint is skipped when context commit fails", async () => { - let afterCheckpointCalled = false; - let commitCount = 0; + test("gate.blocked and the persisted op carry the approval snapshot when tools are wired", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; - const store = makeContextStore(); + const askExtension = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + approvalTimeoutMs: 60_000, + toolDefinitions: [ + { + name: "charge_card", + description: "Charge the customer's card", + inputSchema: { type: "object" }, + }, + ], + }); + + const { reactor, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: directorFromTable( + { + "message.received": (_e, _s, caps) => caps.infer(), + "inference.done": (_e, _s, caps) => + caps.executeTools([ + { id: "call-ask", name: "charge_card", arguments: {} }, + ]), + "reactor.gate.cleared": (_e, _s, caps) => caps.done(), + }, + "wait", + ), + inferenceRunner: makeInferenceRunner({ + type: "done", + turn: suspendToolCallTurn, + usage: emptyUsage(), + }), + beforeToolExtensions: [askExtension], + }); + + const expectedSnapshot = { + name: "charge_card", + description: "Charge the customer's card", + inputSchema: { type: "object" }, + arguments: {}, + }; + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + expect(blocked.data.approvalSnapshot).toEqual(expectedSnapshot); + + // The snapshot rides the persisted pending op too, so a rehydrated op + // still carries it. + const op = cell.pendingOperations[0]; + if (op === undefined) throw new Error("unreachable"); + expect(op.approvalSnapshot).toEqual(expectedSnapshot); + + reactor.abort("admin_kill"); + await waitFor("reactor.done"); + }); + + test("a before-tool suspension in a cycle with no inference and no completed tool call still persists the pending op", async () => { + // The suspension is raised from a message.received handler that dispatches + // executeTools directly, so the cycle runs no inference and completes no + // tool call (the sole call is parked). Registering the gate and pending + // operation is nonetheless durable state that must be committed; otherwise + // the pending op lives only in memory and is lost on restart. + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const askExtension = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + approvalTimeoutMs: 60_000, + }); + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: directorFromTable( + { + "message.received": (_e, _s, caps) => + caps.executeTools([ + { id: "call-ask", name: "charge_card", arguments: {} }, + ]), + "reactor.gate.cleared": (_e, _s, caps) => caps.done(), + }, + "wait", + ), + beforeToolExtensions: [askExtension], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) + throw new Error("expected reactor.gate.blocked to carry a correlationId"); + + // No inference and no completed tool call ran in this cycle. + expect(events.some((e) => e.type === "inference.done")).toBe(false); + expect(events.some((e) => e.type === "tool.start")).toBe(false); + expect(events.some((e) => e.type === "tool.done")).toBe(false); + + // The suspension forced a durable commit: the pending op was written to + // the context store, so a reload would recover it. + expect(cell.pendingOperations).toHaveLength(1); + const op = cell.pendingOperations[0]; + if (op === undefined) throw new Error("unreachable"); + expect(op.kind).toBe("approval"); + expect(op.correlationId).toBe(correlationId); + expect(op.gateId).toBe(`pending-${correlationId}`); + + reactor.abort("admin_kill"); + await waitFor("reactor.done"); + }); + + test("a suspended agent rehydrates a live gate on restart and re-dispatches the parked call on approval delivery", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const askExtension = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + approvalTimeoutMs: 60_000, + }); + + // Phase 1: suspend and persist, then tear down. + const first = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: directorFromTable( + { + "message.received": (_e, _s, caps) => caps.infer(), + "inference.done": (_e, _s, caps) => + caps.executeTools([ + { id: "call-ask", name: "charge_card", arguments: {} }, + ]), + }, + "wait", + ), + inferenceRunner: makeInferenceRunner({ + type: "done", + turn: suspendToolCallTurn, + usage: emptyUsage(), + }), + beforeToolExtensions: [askExtension], + }); + + first.reactor.start(); + first.reactor.deliver(makeInboundMessage()); + const blocked = await first.waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + first.reactor.abort("admin_kill"); + await first.waitFor("reactor.done"); + + // The persisted op survives the teardown, carrying the parked call so the + // resume can re-run it. + expect(cell.pendingOperations).toHaveLength(1); + expect(cell.pendingOperations[0]?.suspendedCall?.id).toBe("call-ask"); + + // Phase 2: reload from the persisted cell. A restarted reactor with a + // rehydrated gate matches the delivered approval and re-runs the parked + // call; without rehydration the delivered message would not match a live + // gate and the reactor would stay wedged (this test times out). + const toolsRun: string[] = []; + const second = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: directorFromTable( + { + "resume.execute_tools": (e, _s, caps) => + caps.executeTools(e.calls, false, true), + "tool.done": (_e, _s, caps) => caps.done(), + }, + "wait", + ), + toolRunner: makeToolRunner(async (call) => { + toolsRun.push(call.name); + return { callId: call.id, content: "charged" }; + }), + beforeToolExtensions: [askExtension], + }); + + second.reactor.start(); + await second.waitFor("reactor.start"); + + second.reactor.deliver(makeApprovalMessage(correlationId)); + + const toolDone = await second.waitFor("tool.done"); + if (toolDone.type !== "tool.done") throw new Error("unreachable"); + expect(toolDone.data.result.callId).toBe("call-ask"); + + const correlated = getEvent(second.events, "message.correlated"); + expect(correlated.data.correlationId).toBe(correlationId); + + // The one-shot bypass let the re-dispatched call through without + // re-parking: it ran exactly once and no second gate was blocked. + expect(toolsRun).toEqual(["charge_card"]); + expect(second.events.some((e) => e.type === "reactor.gate.blocked")).toBe( + false, + ); + + await second.waitFor("reactor.done"); + }); +}); + +// --------------------------------------------------------------------------- +// Approval resume re-runs the parked tool call (re-dispatch rail) +// --------------------------------------------------------------------------- + +describe("createReactor — approval resume re-dispatch", () => { + // A two-phase inference runner: the first inference emits the tool_call that + // parks on the ask gate; the re-inference after the re-dispatched call + // completes emits a plain text reply that terminates the run. Mirrors how a + // real model first calls a tool and then answers with the tool's result. + function twoPhaseInferenceRunner() { + let call = 0; + return async function* ( + opts: InferenceHarnessOptions, + ): AsyncGenerator { + call += 1; + const turn: AssistantTurn = + call === 1 + ? suspendToolCallTurn + : { + role: "assistant", + content: [{ type: "text", text: "done charging" }], + model: "test-model", + timestamp: 2000, + }; + yield { + type: "inference.done", + seq: opts.nextSeq(), + data: { turn, usage: emptyUsage(), source: TEST_SOURCE }, + }; + }; + } + + function askExtension() { + return createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + approvalTimeoutMs: 60_000, + }); + } + + test("an approved correlation re-runs the parked tool exactly once and answers its call id", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + const toolsRun: string[] = []; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => { + toolsRun.push(call.name); + return { callId: call.id, content: "charged" }; + }), + beforeToolExtensions: [askExtension()], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + // The parked call has not run yet. + expect(toolsRun).toEqual([]); + + reactor.deliver(makeApprovalMessage(correlationId)); + + // The resume re-runs the parked tool and the model re-infers to a reply + // that carries the tool's real result, terminating the run. + const reply = await waitForEvent( + events, + (e) => e.type === "connector.reply", + ); + if (reply.type !== "connector.reply") throw new Error("unreachable"); + expect(reply.data.content).toBe("done charging"); + + // The tool ran exactly once — the re-dispatch, not a fresh re-inference + // that re-issued the call. + expect(toolsRun).toEqual(["charge_card"]); + const toolDones = events.filter((e) => e.type === "tool.done"); + expect(toolDones).toHaveLength(1); + + // The re-dispatch appended a tool_result answering the parked call id, and + // the persisted history is a well-formed tool sequence. + const resultTurn = cell.turns.find((t) => + t.content.some( + (b) => b.type === "tool_result" && b.callId === "call-ask", + ), + ); + expect(resultTurn).toBeDefined(); + expect(() => assertWellFormedToolSequence(cell.turns)).not.toThrow(); + + // The correlation was claimed and the parked op removed. + const correlated = getEvent(events, "message.correlated"); + expect(correlated.data.correlationId).toBe(correlationId); + expect(cell.pendingOperations).toHaveLength(0); + }); + + test("the re-dispatched approved call re-infers exactly once and leaves no outstanding results", async () => { + // This guards the pendingToolResults counter trap. A re-dispatch driven + // from the correlation path never passes through inference.done, so unless + // the director seeds its outstanding-result count off resume.execute_tools, + // the count sits at zero and the re-dispatched call's tool.done drives an + // accidental re-inference off a negative count. The seed makes the + // continuation deterministic: exactly one re-inference after exactly one + // re-dispatched result. + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => ({ + callId: call.id, + content: "charged", + })), + beforeToolExtensions: [askExtension()], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + reactor.deliver(makeApprovalMessage(correlationId)); + await waitForEvent(events, (e) => e.type === "connector.reply"); + + // Two inferences total: the initial tool-call inference and exactly one + // continuation re-inference after the re-dispatched tool completed. A + // counter left unseeded would either hang (no re-infer) or, once the + // negative-count accident is removed, fail to continue at all. + const inferenceDones = events.filter((e) => e.type === "inference.done"); + expect(inferenceDones).toHaveLength(2); + + // Exactly one tool ran and one result was produced, so the director's + // outstanding count returned to zero (a re-infer fires only at zero). + const toolDones = events.filter((e) => e.type === "tool.done"); + expect(toolDones).toHaveLength(1); + + // A conversational reply returns the reactor to idle rather than shutting + // it down; abort so the test does not leak the reactor. + reactor.abort("admin_kill"); + await waitFor("reactor.done"); + }); + + test("the one-shot bypass lets the re-dispatched call through without re-parking", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => ({ + callId: call.id, + content: "charged", + })), + beforeToolExtensions: [askExtension()], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + reactor.deliver(makeApprovalMessage(correlationId)); + await waitForEvent(events, (e) => e.type === "connector.reply"); + + // The re-dispatched call re-hit the ask extension but the one-shot bypass + // let it through: exactly one gate was ever blocked (the original park), + // and no pending op survives — the call did not re-park on a second gate. + const gateBlocks = events.filter((e) => e.type === "reactor.gate.blocked"); + expect(gateBlocks).toHaveLength(1); + expect(cell.pendingOperations).toHaveLength(0); + + // A conversational reply returns the reactor to idle rather than shutting + // it down; abort so the test does not leak the reactor. + reactor.abort("admin_kill"); + await waitFor("reactor.done"); + }); + + test("a non-JSON approval body halts the run with a fatal reactor.error", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => ({ + callId: call.id, + content: "charged", + })), + beforeToolExtensions: [askExtension()], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + // Deliver a correlated body that is not valid JSON. The parse boundary must + // reject it rather than resuming on a value it cannot decode. + reactor.deliver( + createInboundMessage({ + from: "signal@local", + to: "agent@example.com", + content: "not json at all", + correlationId, + }), + ); + + const error = await waitFor("reactor.error"); + if (error.type !== "reactor.error") throw new Error("unreachable"); + expect(error.data.fatal).toBe(true); + expect(error.data.error).toContain("Correlation dispatch failed"); + + // A malformed decision halts the run rather than silently proceeding: the + // tool never runs and the reactor shuts down. + expect(events.some((e) => e.type === "tool.start")).toBe(false); + await waitFor("reactor.done"); + }); + + test("a schema-invalid approval body halts the run with a fatal reactor.error", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => ({ + callId: call.id, + content: "charged", + })), + beforeToolExtensions: [askExtension()], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + // Valid JSON, but not a valid ApprovalDecision: "maybe" is not an outcome + // the schema admits. The arktype boundary must reject it as fatal. + reactor.deliver( + createInboundMessage({ + from: "signal@local", + to: "agent@example.com", + content: JSON.stringify({ outcome: "maybe" }), + correlationId, + }), + ); + + const error = await waitFor("reactor.error"); + if (error.type !== "reactor.error") throw new Error("unreachable"); + expect(error.data.fatal).toBe(true); + expect(error.data.error).toContain("Correlation dispatch failed"); + + expect(events.some((e) => e.type === "tool.start")).toBe(false); + await waitFor("reactor.done"); + }); +}); + +// --------------------------------------------------------------------------- +// Approval resume answers a rejected or timed-out parked call with an error +// result (the shared resume.tool_result rail) +// --------------------------------------------------------------------------- + +describe("createReactor — approval resume error result", () => { + // A two-phase inference runner: the first inference emits the tool_call that + // parks on the ask gate; the re-inference after the parked call is answered + // with an error result emits a plain text reply that terminates the run. + function twoPhaseInferenceRunner() { + let call = 0; + return async function* ( + opts: InferenceHarnessOptions, + ): AsyncGenerator { + call += 1; + const turn: AssistantTurn = + call === 1 + ? suspendToolCallTurn + : { + role: "assistant", + content: [{ type: "text", text: "acknowledged" }], + model: "test-model", + timestamp: 2000, + }; + yield { + type: "inference.done", + seq: opts.nextSeq(), + data: { turn, usage: emptyUsage(), source: TEST_SOURCE }, + }; + }; + } + + function askExtension(approvalTimeoutMs = 60_000) { + return createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + approvalTimeoutMs, + }); + } + + test("a rejected correlation answers the parked call with an error result and re-infers once", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + const toolsRun: string[] = []; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => { + toolsRun.push(call.name); + return { callId: call.id, content: "charged" }; + }), + beforeToolExtensions: [askExtension()], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + // Deliver a rejection carrying an approver reason. + reactor.deliver( + createInboundMessage({ + from: "signal@local", + to: "agent@example.com", + content: JSON.stringify({ outcome: "rejected", message: "too risky" }), + correlationId, + }), + ); + + // The reactor answers the parked call with a synthetic error result and + // re-infers off it, producing the terminating reply. + const reply = await waitForEvent( + events, + (e) => e.type === "connector.reply", + ); + if (reply.type !== "connector.reply") throw new Error("unreachable"); + expect(reply.data.content).toBe("acknowledged"); + + // The tool never ran: rejection does not grant the one-shot bypass. + expect(toolsRun).toEqual([]); + expect(events.some((e) => e.type === "tool.start")).toBe(false); + expect(events.some((e) => e.type === "tool.done")).toBe(false); + + // History carries an error tool_result answering the parked call id, and + // the persisted sequence is well-formed. + const resultTurn = cell.turns.find((t) => + t.content.some( + (b) => + b.type === "tool_result" && + b.callId === "call-ask" && + b.isError === true, + ), + ); + expect(resultTurn).toBeDefined(); + expect(() => assertWellFormedToolSequence(cell.turns)).not.toThrow(); + + // Exactly one re-inference after the park: the initial tool-call inference + // plus one continuation off the error result. + const inferenceDones = events.filter((e) => e.type === "inference.done"); + expect(inferenceDones).toHaveLength(2); + + // The correlation was claimed and the parked op removed. + const correlated = getEvent(events, "message.correlated"); + expect(correlated.data.correlationId).toBe(correlationId); + expect(cell.pendingOperations).toHaveLength(0); + }); + + test("a rejected correlation surfaces the approver reason in the error content", async () => { + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => ({ + callId: call.id, + content: "charged", + })), + beforeToolExtensions: [askExtension()], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + reactor.deliver( + createInboundMessage({ + from: "signal@local", + to: "agent@example.com", + content: JSON.stringify({ outcome: "rejected", message: "too risky" }), + correlationId, + }), + ); + + await waitForEvent(events, (e) => e.type === "connector.reply"); + + const resultBlock = cell.turns + .flatMap((t) => t.content) + .find((b) => b.type === "tool_result" && b.callId === "call-ask"); + if (resultBlock === undefined || resultBlock.type !== "tool_result") { + throw new Error("expected an error tool_result for the parked call"); + } + const text = resultBlock.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); + expect(text).toBe("denied by approver: too risky"); + }); + + test("a timed-out parked call answers with an error result and re-infers exactly once", async () => { + // The double-infer regression guard. A gate timeout on a parked ask call + // must enqueue resume.tool_result INSTEAD OF reactor.gate.cleared. If the + // fork ever enqueued both, the parked call would drive two re-inferences + // for one timeout. Assert exactly one continuation inference. + const cell: PersistedCell = { + turns: [], + pendingOperations: [], + tokenUsage: emptyUsage(), + }; + const toolsRun: string[] = []; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + director: createDefaultDirector("test agent", []), + inferenceRunner: twoPhaseInferenceRunner(), + toolRunner: makeToolRunner(async (call) => { + toolsRun.push(call.name); + return { callId: call.id, content: "charged" }; + }), + beforeToolExtensions: [askExtension(80)], + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const blocked = await waitFor("reactor.gate.blocked"); + if (blocked.type !== "reactor.gate.blocked") throw new Error("unreachable"); + const correlationId = blocked.data.correlationId; + if (correlationId === undefined) throw new Error("expected correlationId"); + + // No decision is delivered; the gate times out on its own. + const reply = await waitForEvent( + events, + (e) => e.type === "connector.reply", + ); + if (reply.type !== "connector.reply") throw new Error("unreachable"); + expect(reply.data.content).toBe("acknowledged"); + + // The tool never ran, and no plain gate-cleared drove a second re-infer. + expect(toolsRun).toEqual([]); + expect(events.some((e) => e.type === "reactor.gate.cleared")).toBe(false); + + // History carries a timeout error tool_result answering the parked call id. + const resultBlock = cell.turns + .flatMap((t) => t.content) + .find((b) => b.type === "tool_result" && b.callId === "call-ask"); + if (resultBlock === undefined || resultBlock.type !== "tool_result") { + throw new Error("expected an error tool_result for the parked call"); + } + expect(resultBlock.isError).toBe(true); + const text = resultBlock.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); + expect(text).toBe("approval timed out"); + expect(() => assertWellFormedToolSequence(cell.turns)).not.toThrow(); + + // Exactly one continuation inference: the initial tool-call inference plus + // one re-inference off the timeout error result — never two. + const inferenceDones = events.filter((e) => e.type === "inference.done"); + expect(inferenceDones).toHaveLength(2); + + // The parked op was removed. + expect(cell.pendingOperations).toHaveLength(0); + }); + + test("a gate with no suspendedCall-bearing op still resumes on a bare gate-cleared timeout", async () => { + // The non-ask rail (a director-suspended gate, or an async-marker pending + // op that carries no suspendedCall) must keep today's behavior on timeout: + // a plain reactor.gate.cleared drives the re-infer, with no synthetic tool + // result manufactured. The fork only diverts a gate whose op has a + // suspendedCall. + let cleared = false; + const director = directorFromTable({ + "message.received": (_e, _s, caps) => + caps.suspend({ + type: "approval", + gateId: "pending-async-marker", + timeoutMs: 80, + correlationId: "async-marker-corr", + }), + "reactor.gate.cleared": (_e, _s, caps) => { + cleared = true; + return caps.done(); + }, + }); + + const { reactor, waitFor } = createTestReactor({ director }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + + const gateCleared = await waitFor("reactor.gate.cleared"); + if (gateCleared.type !== "reactor.gate.cleared") { + throw new Error("unreachable"); + } + expect(gateCleared.data.reason).toBe("timeout"); + await waitFor("reactor.done"); + expect(cleared).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Corrupt persisted state surfaces loud at startup +// --------------------------------------------------------------------------- + +describe("createReactor — corrupt persisted state", () => { + test("duplicate correlationId in persisted pending operations emits reactor.error and reactor.done", async () => { + // The pending operations come from the context store, an untrusted + // external boundary. Two operations sharing a correlationId make the + // second correlation registration throw during rehydration. That failure + // must surface as reactor.error plus reactor.done, exactly like a load + // failure, rather than bricking the reactor with no lifecycle events. + const now = Date.now(); + const duplicate: PendingOperation[] = [ + { + correlationId: "dup-corr", + kind: "approval", + registeredAt: now, + gateId: "gate-a", + timeoutAt: now + 60_000, + }, + { + correlationId: "dup-corr", + kind: "approval", + registeredAt: now, + gateId: "gate-b", + timeoutAt: now + 60_000, + }, + ]; + + const cell: PersistedCell = { + turns: [], + pendingOperations: duplicate, + tokenUsage: emptyUsage(), + }; + + const { reactor, events, waitFor } = createTestReactor({ + contextStore: makePersistingContextStore(cell), + }); + + reactor.start(); + + await waitFor("reactor.error"); + await waitFor("reactor.done"); + + const error = getEvent(events, "reactor.error"); + expect(error.data.fatal).toBe(true); + expect(error.data.error).toMatch(/dup-corr/); + + // The brick symptom is the absence of lifecycle events; assert both fired. + expect(events.some((e) => e.type === "reactor.done")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Lifecycle hooks: afterCheckpoint and onShutdown +// --------------------------------------------------------------------------- + +describe("createReactor — afterCheckpoint", () => { + test("afterCheckpoint is called after successful checkpoint", async () => { + let afterCheckpointCalled = false; + + const { reactor, waitFor } = createTestReactor({ + director: directorFromTable({ + "message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()], + }), + afterCheckpoint: async () => { + afterCheckpointCalled = true; + }, + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + await waitFor("reactor.done"); + expect(afterCheckpointCalled).toBe(true); + }); + + test("afterCheckpoint is skipped when context commit fails", async () => { + let afterCheckpointCalled = false; + let commitCount = 0; + + const store = makeContextStore(); const originalCommit = store.commit.bind(store); async function wrappedCommit( options: { message: string }, @@ -4287,15 +5513,16 @@ describe("createReactor — source failover", () => { expect(done.data.source.sourceId).toBe("s1"); }); - test("retries the same source on a rate limit before failing over", async () => { + test("fails over on quota exhaustion already retried by the harness", async () => { const { reactor, events, waitFor, attemptedSourceIds } = multiSourceReactor( { sourceIds: ["s0", "s1"], - // s0 is always rate-limited (short Retry-After so the test stays - // fast); s1 succeeds. + // s0 is quota-exhausted; the harness wrapper owns quota retry and + // has exhausted it by the time the reactor sees the error, so the + // reactor fails over rather than re-running s0. resultFor: (id) => id === "s0" - ? { category: "quota_exhausted", message: "429", retryAfterMs: 1 } + ? { category: "quota_exhausted", message: "quota exhausted" } : "done", }, ); @@ -4303,8 +5530,7 @@ describe("createReactor — source failover", () => { reactor.deliver(makeInboundMessage()); await waitFor("reactor.done"); - // s0 gets its same-source budget (2 attempts) before failover to s1. - expect(attemptedSourceIds).toEqual(["s0", "s0", "s1"]); + expect(attemptedSourceIds).toEqual(["s0", "s1"]); expect(getEvent(events, "inference.done").data.source.sourceId).toBe("s1"); }); @@ -4365,80 +5591,6 @@ describe("createReactor — source failover", () => { }); }); -describe("createReactor — retry re-emission", () => { - // Regression: the reactor's outer retry loop (same-source quota retry and - // failover) re-invokes inferenceRunner from scratch on retry, which - // re-streams inference.start through whatever the failed attempt already - // committed. Without a marker between the discarded attempt's events and - // the retried attempt's own, a consumer replaying the event stream has no - // way to tell the two apart and ends up rendering the failed attempt's - // content twice. - test("emits inference.retry before re-streaming a same-source quota retry", async () => { - let call = 0; - const inferenceRunner = async function* ( - o: InferenceHarnessOptions, - ): AsyncGenerator { - call += 1; - if (call === 1) { - // First attempt commits partial content, then fails. - yield { - type: "inference.text.delta", - seq: o.nextSeq(), - data: { token: "partial", partial: { text: "partial" } }, - }; - yield { - type: "inference.error", - seq: o.nextSeq(), - data: { - error: { - category: "quota_exhausted", - message: "429", - retryAfterMs: 1, - }, - partial: { text: "partial" }, - }, - }; - return; - } - yield { - type: "inference.done", - seq: o.nextSeq(), - data: { - turn: makeAssistantTurn("final reply"), - usage: emptyUsage(), - source: TEST_SOURCE, - }, - }; - }; - - const { reactor, events, waitFor } = createTestReactor({ - inferenceRunner, - director: directorFromTable({ - "message.received": (_e, _s, caps) => caps.infer(), - "inference.done": (_e, _s, caps) => caps.done(), - "inference.error": (_e, _s, caps) => caps.done(), - }), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - const deltaIndex = events.findIndex( - (e) => e.type === "inference.text.delta", - ); - const retryIndex = events.findIndex((e) => e.type === "inference.retry"); - const doneIndex = events.findIndex((e) => e.type === "inference.done"); - - // The failed attempt's committed content must be followed by a retry - // marker before the retried attempt's own terminal event, so a consumer - // can discard the discarded attempt's blocks on retry. - expect(deltaIndex).toBeGreaterThanOrEqual(0); - expect(retryIndex).toBeGreaterThan(deltaIndex); - expect(doneIndex).toBeGreaterThan(retryIndex); - }); -}); - describe("createReactor — prompt well-formedness tripwire", () => { test("rejects a malformed assembled prompt before inferring", async () => { // History with two tool_result blocks for one callId is the shape @@ -4506,217 +5658,3 @@ describe("createReactor — prompt well-formedness tripwire", () => { expect(inferenceRan).toBe(false); }); }); - -// --------------------------------------------------------------------------- -// Per-event overhead: history is not re-serialized or re-copied needlessly -//. Directors that decide without changing history must not drive a -// full-history write, and high-frequency delta events must not reach the -// checkpoint path at all. -// --------------------------------------------------------------------------- - -function makeCountingContextStore(): { - store: ContextStore; - writeTurnsCalls: number; - commitCount: number; -} { - let writeTurnsCalls = 0; - let commitCount = 0; - const store: ContextStore = { - async load() { - return { - turns: [], - pendingOperations: [], - tokenUsage: emptyUsage(), - connectorState: null, - }; - }, - setConnectorState() { - /* noop */ - }, - async commit(options: { message: string }) { - commitCount++; - return { - hash: `c${String(commitCount)}`, - message: options.message, - timestamp: Date.now(), - }; - }, - async branch() { - /* noop */ - }, - async log() { - return []; - }, - async readAt() { - return []; - }, - async writeBlob() { - /* noop */ - }, - async readBlob() { - throw new Error("not implemented"); - }, - async writePrompt() { - /* noop */ - }, - async writeResponse() { - /* noop */ - }, - async writeManifest() { - /* noop */ - }, - async writeTurns() { - writeTurnsCalls++; - }, - async writeMetadata() { - /* noop */ - }, - async readManifestHistory() { - throw new Error("not implemented"); - }, - }; - return { - store, - get writeTurnsCalls() { - return writeTurnsCalls; - }, - get commitCount() { - return commitCount; - }, - }; -} - -describe("createReactor — bounded per-event persistence overhead", () => { - test("streaming delta events never reach the checkpoint write path", async () => { - const counting = makeCountingContextStore(); - const deltaTokens = ["one ", "two ", "three ", "four ", "five "]; - - const { reactor, events, waitFor } = createDirectReactor({ - contextStore: counting.store, - director: directorFromTable({ - "message.received": (_e, _s, caps) => caps.infer(), - "inference.done": (_e, _s, caps) => [ - caps.checkpoint("done"), - caps.done(), - ], - }), - inferenceRunner: async function* (opts) { - for (const token of deltaTokens) { - yield { - type: "inference.text.delta", - seq: opts.nextSeq(), - data: { token, partial: { text: token } }, - }; - } - yield { - type: "inference.done", - seq: opts.nextSeq(), - data: { - turn: makeAssistantTurn("five tokens"), - usage: emptyUsage(), - source: TEST_SOURCE, - }, - }; - }, - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - const emittedDeltas = events.filter( - (e) => e.type === "inference.text.delta", - ); - expect(emittedDeltas.length).toBe(deltaTokens.length); - - // The whole exchange changes history exactly once (inbound turn + the - // single assistant turn committed together), so writeTurns fires once — - // never per delta. - expect(counting.writeTurnsCalls).toBe(1); - }); - - test("a checkpoint whose history is unchanged does not rewrite turns", async () => { - const counting = makeCountingContextStore(); - - const { reactor, waitFor } = createDirectReactor({ - contextStore: counting.store, - director: { - async decide(event, _state, caps) { - if (event.type === "message.received") { - return [ - caps.checkpoint("pre-suspend"), - caps.suspend({ - type: "approval", - gateId: "unchanged-history-gate", - timeoutMs: 50, - }), - ]; - } - if (event.type === "reactor.gate.cleared") { - // Resume checkpoint: real cycle work, but history has not moved - // since the suspend commit, so no turns should be rewritten. - return [caps.checkpoint("resumed"), caps.done()]; - } - return caps.done(); - }, - }, - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - // Two checkpoint commits: the suspend and the resume. - expect(counting.commitCount).toBe(2); - // Only the first changed history (the inbound turn); the resume commit - // must skip the full-history write. - expect(counting.writeTurnsCalls).toBe(1); - }); -}); - -describe("createStateManager — lazy snapshot fields", () => { - test("snapshot defers the turns copy and memoizes it per snapshot", () => { - const history = Array.from({ length: 200 }, (_v, i) => - makeAssistantTurn(`turn-${String(i)}`), - ); - const manager = createStateManager( - "session-lazy", - history, - [], - emptyUsage(), - ); - - const snap = manager.snapshot(); - const first = snap.turns; - const second = snap.turns; - // Memoized: the same snapshot hands back the identical array instance. - expect(second).toBe(first); - expect(first.length).toBe(history.length); - - // A separate snapshot produces an independent container so a reader cannot - // corrupt reactor state, while sharing the frozen turn references. - const otherTurns = manager.snapshot().turns; - expect(otherTurns).not.toBe(first); - expect(otherTurns[0]).toBe(first[0]); - }); - - test("turns revision advances on append and replace, not on read", () => { - const manager = createStateManager( - "session-revision", - [makeAssistantTurn("seed")], - [], - emptyUsage(), - ); - - expect(manager.getTurnsRevision()).toBe(0); - // Reading history must not bump the revision. - void manager.snapshot().turns; - expect(manager.getTurnsRevision()).toBe(0); - - manager.appendTurn(makeAssistantTurn("next")); - expect(manager.getTurnsRevision()).toBe(1); - - manager.replaceTurns([makeAssistantTurn("compacted")]); - expect(manager.getTurnsRevision()).toBe(2); - }); -}); diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index eb135443b..60480cd38 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -27,7 +27,10 @@ import type { ToolResult, ToolCall, AbortReason, + BeforeToolDecision, BeforeToolExtension, + GateType, + PendingOperation, ReactorAction, ToolResultTransform, ContextTransform, @@ -38,6 +41,8 @@ import type { } from "@intx/types/runtime"; import { getLogger } from "@intx/log"; +import { ApprovalDecision, signalKindToGateType } from "@intx/types"; +import { type } from "arktype"; import { runInference } from "./harness"; import type { Dependencies, InferenceHarnessOptions } from "./harness"; import { createCapabilities } from "./director"; @@ -54,11 +59,25 @@ import type { CorrelationValidator } from "./correlation"; const logger = getLogger(["interchange", "reactor"]); +// Sentinel returned by a per-call tool run when a before-tool extension parked +// the call on a gate. Distinct from every ToolResult so a suspended call is +// excluded from the tool-result history append and from tool.done continuation. +const SUSPENDED = Symbol("suspended"); + +// Exhaustiveness guard for the resume-dispatch switch. A newly added +// SignalKind or approval outcome that is not classified fails to type-check +// here, so the switch cannot silently drop an unhandled case. +function assertNever(x: never): never { + throw new Error(`Unhandled resume case: ${JSON.stringify(x)}`); +} + /** * `InferenceOptions` plus vendored-only fields the published `@intx/types` * does not carry. `ephemeralTurns` are appended to the materialized prompt * for one inference only and never written to durable history, so transient * director guidance leaves the cached transcript prefix untouched. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts */ export type ExtendedInferenceOptions = InferenceOptions & { ephemeralTurns?: ConversationTurn[]; @@ -314,6 +333,10 @@ export function createReactor(config: ReactorConfig): Reactor { let cycleInferred = false; let cycleToolCallsExecuted = 0; let cycleCompactorName: string | null = null; + // A suspension registers a gate and may persist a pending operation. That is + // a durable state change even when the cycle ran no inference and completed + // no tool call, so it must force the cycle commit. + let cycleSuspended = false; // Director-supplied checkpoint message override; consumed exactly once. let pendingMessage: string | null = null; @@ -347,6 +370,96 @@ export function createReactor(config: ReactorConfig): Reactor { // across an await boundary in the validator, causing double-correlation. const correlatingIds = new Set(); + // How the reactor resumes a correlated pending operation. + // + // redispatch — an approved approval re-runs its parked tool call. The + // reactor grants a one-shot bypass for the call and re-dispatches it; + // the resumed run answers the parked call with a real tool result. The + // correlated message body is the decision, not conversation content, so + // it is NOT appended to history. + // gate-cleared — the async-tool path (a pending marker awaiting an inbound + // response). The gate clears normally, driving the director to re-infer, + // and the correlated message body IS appended to history so the model + // sees the response it was waiting on. + type ResumeDispatch = + | { mode: "redispatch"; calls: ToolCall[] } + | { mode: "gate-cleared" } + | { mode: "error_result"; result: ToolResult }; + + // Decide how a correlated approval-kind pending operation resumes, granting + // any one-shot bypass synchronously so no delivery can interleave between the + // grant and the re-dispatch enqueued by the caller. An operation that carries + // a `suspendedCall` is an ask-flow suspension: the approver's decision routes + // it down the re-dispatch rail. An operation without one is an async-tool + // pending marker, which resumes on the normal gate-cleared rail. + // + // The nested switch is total: the outer `assertNever(op.kind)` rejects a + // future SignalKind at compile time, and the inner `assertNever` rejects a + // future decision outcome. A malformed decision body fails loud at the parse + // boundary before the switch. + function resumePendingOperation( + op: PendingOperation, + message: InboundMessage, + ): ResumeDispatch { + if (op.suspendedCall === undefined) { + return { mode: "gate-cleared" }; + } + const suspendedCall = op.suspendedCall; + + if (message.content === undefined) { + throw new Error( + `Correlated approval decision for ${op.correlationId} has no body to parse`, + ); + } + let raw: unknown; + try { + raw = JSON.parse(message.content); + } catch (cause) { + throw new Error( + `Correlated approval decision for ${op.correlationId} is not valid JSON`, + { cause }, + ); + } + const decision = ApprovalDecision(raw); + if (decision instanceof type.errors) { + throw new Error( + `Correlated approval decision for ${op.correlationId} is malformed: ${decision.summary}`, + ); + } + + switch (op.kind) { + case "approval": + switch (decision.outcome) { + case "approved": + // Authorize the exact parked call to run once, then re-dispatch it. + // Grant on every before-tool extension: only the authz extension + // responds, but referencing it directly would re-couple the reactor + // to authz and break a deployment that runs without it. + for (const ext of beforeToolExtensions) { + ext.grantOneShot?.(suspendedCall.id); + } + return { mode: "redispatch", calls: [suspendedCall] }; + case "rejected": { + // The approver denied the call. Answer the parked call with a + // synthetic error result rather than re-running it — no one-shot + // bypass is granted, so the tool never executes. The approver's + // reason, when present, is surfaced to the model verbatim. + const content = + "denied by approver" + + (decision.message !== undefined ? `: ${decision.message}` : ""); + return { + mode: "error_result", + result: { callId: suspendedCall.id, content, isError: true }, + }; + } + default: + return assertNever(decision.outcome); + } + default: + return assertNever(op.kind); + } + } + async function tryCorrelate(message: InboundMessage): Promise { const correlationId = message.headers.interchangeCorrelationId; if (correlationId === undefined) return false; @@ -359,6 +472,8 @@ export function createReactor(config: ReactorConfig): Reactor { // A finally clears the in-flight marker on every exit — success included. // The success path used to leave the id in the set forever, leaking one // entry per correlated message for the life of the session. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts try { if (correlationValidator !== undefined) { let valid: boolean; @@ -373,22 +488,74 @@ export function createReactor(config: ReactorConfig): Reactor { } } - // Clear the gate associated with this correlation, if any. - const gate = gates.findByCorrelationId(correlationId); - if (gate !== undefined) { - gates.clear(gate.gateId); - } + // Capture the operation before removal so the resume dispatch can read + // its kind and suspended call. Removal happens only after the dispatch + // is decided, all inside this correlatingIds-guarded critical section + // so a double-deliver early-returns rather than double-dispatching. + const op = pending; - correlations.remove(correlationId); + const dispatch = resumePendingOperation(op, message); - if (stateManager !== null) { - stateManager.removePendingOperation(correlationId); - - // Append the correlated message to conversation history so the model - // sees the response content when it re-infers after the gate clears. - const msg = createInboundTurn(message); - if (msg !== null) { - stateManager.appendTurn(msg); + const gate = gates.findByCorrelationId(correlationId); + switch (dispatch.mode) { + case "redispatch": { + // Clear the gate WITHOUT enqueuing gate.cleared: the re-dispatched + // call is the resumption, so a gate.cleared-driven re-infer would + // double the continuation. The re-dispatch's own tool.done drives + // the re-infer. + if (gate !== undefined) { + gates.clearSilently(gate.gateId); + if (stateManager !== null) { + stateManager.setGatesSnapshot(gates.snapshot()); + } + } + correlations.remove(correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(correlationId); + } + // The grant is already recorded (synchronously, in + // resumePendingOperation) with no await since; enqueue the + // re-dispatch so it runs on the loop with normal event ordering. + // The director seeds its outstanding-result count off this event + // before the call's tool.done arrives. + enqueue({ type: "resume.execute_tools", calls: dispatch.calls }); + break; + } + case "error_result": { + // The approver denied the call. Clear the gate SILENTLY (like the + // approved redispatch) so it cannot also trip onGateCleared and + // enqueue a second continuation. The synthetic error result + // answers the parked call; the director appends it and re-infers + // once. + if (gate !== undefined) { + gates.clearSilently(gate.gateId); + if (stateManager !== null) { + stateManager.setGatesSnapshot(gates.snapshot()); + } + } + correlations.remove(correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(correlationId); + } + enqueue({ type: "resume.tool_result", result: dispatch.result }); + break; + } + case "gate-cleared": { + // Async-tool resumption: clear the gate normally so the director + // re-infers, and append the correlated response to history so the + // model sees the content it was waiting on. + if (gate !== undefined) { + gates.clear(gate.gateId); + } + correlations.remove(correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(correlationId); + const msg = createInboundTurn(message); + if (msg !== null) { + stateManager.appendTurn(msg); + } + } + break; } } @@ -483,19 +650,10 @@ export function createReactor(config: ReactorConfig): Reactor { } const p = (async () => { - // Per-source attempt budget for transient errors (quota/retryable/ - // timeout). Kept small because failover, not flogging one source, is - // the recovery path: the harness already does its own mechanical - // retry under each attempt, so this caps reactor-level same-source - // retries at one before moving to the next source. - const sameSourceAttempts = 2; - const defaultRetryMs = 60_000; - // Each cycle starts at the most-preferred source; a failover in a // prior cycle must not leave the agent permanently demoted. resetToPreferredSource(); - let attempt = 0; for (;;) { const harnessOpts = buildHarnessOpts( prompt, @@ -577,76 +735,17 @@ export function createReactor(config: ReactorConfig): Reactor { return; } - // A rate limit is the one category worth waiting out on the same - // source: it clears with time, and the reactor's backoff is longer - // than the harness's own per-call retry. The harness has already - // exhausted its internal mechanical retries for retryable/timeout - // by the time the reactor sees them, so those fail over rather than - // re-running the same source (which would just retry-compound). - if (err.category === "quota_exhausted") { - attempt += 1; - if (attempt < sameSourceAttempts && !signal.aborted) { - const delayMs = err.retryAfterMs ?? defaultRetryMs; - logger.warn`Rate limited, retrying same source after ${String(delayMs)}ms`; - // inferenceRunner restarts from a fresh inference.start on the - // next loop iteration, re-streaming any content this attempt - // already committed. Emit the retry marker before that restart - // so a consumer replaying the event stream knows to discard the - // failed attempt's blocks rather than append the retried - // attempt's on top of them. - // - // This overloads inference.retry: the harness emits it only for - // uncommitted pre-first-token retries, where the failed attempt's - // buffered inference.start is discarded — so the harness's retry - // always precedes the cycle's inference.start and retracts - // nothing. This emission instead follows a committed attempt's - // streamed events. Consumers distinguish the two by ordering: a - // retry arriving after the cycle's inference.start retracts that - // attempt's output; one arriving before it does not. - emit({ - type: "inference.retry", - seq: nextSeq(), - data: { attempt, delayMs, previousError: err }, - }); - await new Promise((resolve) => { - const timer = setTimeout(resolve, delayMs); - const onAbort = () => { - clearTimeout(timer); - resolve(); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - if (signal.aborted) { - enqueue({ - type: "inference.error", - error: { - category: "aborted", - message: "inference aborted during rate limit backoff", - }, - partial, - }); - return; - } - continue; - } - } - - // Same-source rate-limit budget exhausted, or a source-specific - // failure (credential, protocol mismatch, retryable, timeout): fail - // over to the next source. A pacing delay the leaving source asked - // for must not gate the next source. + // Any remaining error (quota, credential, protocol mismatch, + // retryable, timeout) is source-specific. The harness wrapper owns + // mechanical retry and has already exhausted it against this source + // by the time the reactor sees the error, including honoring a + // provider Retry-After for quota, so re-running the same source + // would only retry-compound. Fail over to the next source instead. + // A pacing delay the leaving source asked for must not gate the + // next source. pendingPacingDelayMs = 0; if (failOverToNextSource()) { logger.warn`Failing over to next inference source after ${err.category}`; - // Same discard-and-restart concern as the same-source retry above: - // the next source's attempt restarts from inference.start, so mark - // the boundary before it streams anything. - emit({ - type: "inference.retry", - seq: nextSeq(), - data: { attempt, delayMs: 0, previousError: err }, - }); - attempt = 0; continue; } @@ -670,24 +769,44 @@ export function createReactor(config: ReactorConfig): Reactor { const signal = operationController.signal; - const runOne = async (call: ToolCall): Promise => { - // Run before-tool extensions. First block or throw terminates the chain. + const runOne = async ( + call: ToolCall, + ): Promise => { + // Run before-tool extensions. The first non-allow decision terminates + // the chain: `block` answers the call with an error result, `suspend` + // parks it (no result, no tool.done). for (const ext of beforeToolExtensions) { - let blockReason: string | undefined; + let decision: BeforeToolDecision; try { - blockReason = await ext.beforeTool(call, state.snapshot(), signal); + decision = await ext.beforeTool(call, state.snapshot(), signal); } catch (cause) { const msg = cause instanceof Error ? cause.message : String(cause); emitError( `BeforeToolExtension threw for ${call.name}: ${msg}`, false, ); - blockReason = msg; + decision = { type: "block", reason: msg }; } - if (blockReason !== undefined) { + + if (decision.type === "suspend") { + // Park the call: register the gate, persist the pending operation, + // snapshot, and commit. The call is neither run nor answered — no + // tool.start, no tool.done, no tool-result turn. The gate clears + // when the correlated external decision is delivered. + await suspendOnGate({ + gateType: decision.gate.type, + gateId: decision.gate.gateId, + timeoutMs: Math.max(1, decision.gate.timeoutAt - Date.now()), + correlationId: decision.gate.correlationId, + pendingOp: decision.pendingOp, + }); + return SUSPENDED; + } + + if (decision.type === "block") { const blocked: ToolResult = { callId: call.id, - content: blockReason, + content: decision.reason, isError: true, }; emit({ @@ -706,8 +825,12 @@ export function createReactor(config: ReactorConfig): Reactor { if (rawResult.pendingMarker !== undefined && stateManager !== null) { const marker = rawResult.pendingMarker; const gateId = `pending-${marker.correlationId}`; - const op: import("@intx/types/runtime").PendingOperation = { + const op: PendingOperation = { correlationId: marker.correlationId, + // Placeholder: async markers should carry their own SignalKind. The + // resume switch keys on suspendedCall presence (absent here) as the + // interim discriminator instead of on kind. + kind: "approval", registeredAt: Date.now(), gateId, ...(marker.expectedFrom !== undefined @@ -733,29 +856,46 @@ export function createReactor(config: ReactorConfig): Reactor { return current; }; - let results: ToolResult[]; + let outcomes: (ToolResult | typeof SUSPENDED)[]; if (parallel) { const p = Promise.all(calls.map((c) => runOne(c))); track(p); - results = await p; + outcomes = await p; } else { - results = []; + outcomes = []; for (const call of calls) { const p = runOne(call); track(p); - results.push(await p); + outcomes.push(await p); } } + // Suspended calls are parked, not answered: they contribute no tool + // result to history and no tool.done continuation event. + const results = outcomes.filter((o): o is ToolResult => o !== SUSPENDED); + cycleToolCallsExecuted += results.length; - if (addToHistory && stateManager !== null) { + if (addToHistory && stateManager !== null && results.length > 0) { stateManager.appendTurn(createToolResultTurn(results)); } for (const result of results) { enqueue({ type: "tool.done", result }); } + + // Checkpoint the completed tool cycle (the assistant tool_call turn plus + // its results) so an interrupt that rebuilds the agent from the store + // reloads the full exchange. Otherwise context commits only at cycle + // terminals and an uncommitted tool turn vanishes on rebuild. Guarded on + // addToHistory: only then does history end with the tool_result turn, so + // the persisted prefix is well-formed rather than an assistant turn with + // unanswered tool calls. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts + if (addToHistory) { + await commitCycle(); + } } async function executeCompact( @@ -817,6 +957,7 @@ export function createReactor(config: ReactorConfig): Reactor { cycleInferred = false; cycleToolCallsExecuted = 0; cycleCompactorName = null; + cycleSuspended = false; } async function commitCycle(): Promise { @@ -828,7 +969,8 @@ export function createReactor(config: ReactorConfig): Reactor { const hasWork = cycleInferred || cycleToolCallsExecuted > 0 || - cycleCompactorName !== null; + cycleCompactorName !== null || + cycleSuspended; const hasOverride = pendingMessage !== null; if (!hasWork && !hasOverride) { resetCycleAccumulators(); @@ -838,6 +980,7 @@ export function createReactor(config: ReactorConfig): Reactor { const message = buildCycleMessage(); try { + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts const currentRevision = stateManager.getTurnsRevision(); if (currentRevision !== lastWrittenTurnsRevision) { await contextStore.writeTurns(stateManager.getTurns()); @@ -866,6 +1009,8 @@ export function createReactor(config: ReactorConfig): Reactor { // in a later decide() call (as opposed to pairing checkpoint with the // action that produced the work) gets afterCheckpoint invoked twice // for what is, from the director's perspective, a single checkpoint. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts if (afterCheckpoint !== undefined && hasOverride) { try { await afterCheckpoint(); @@ -887,6 +1032,201 @@ export function createReactor(config: ReactorConfig): Reactor { }); } + // ------------------------------------------------------------------------- + // Gate suspension critical section + // ------------------------------------------------------------------------- + + // While a suspend is committing (the `await commitCycle()` in + // `suspendOnGate`), its gate is already armed but `reactor.gate.blocked` has + // not been emitted yet. If the gate's timeout timer elapses inside that + // window, `onGateCleared` would take effect ahead of the `blocked` it belongs + // to — emitting `reactor.gate.cleared` on the plain path, or enqueuing the + // synthetic `resume.tool_result` and removing the pending operation on the + // ask rail — before the suspension has been announced. `deriveStatus` and the + // send-awaiter both assume a gate's `blocked` precedes any effect of its + // clearing, so the in-flight suspend is tracked here and such a clear is + // deferred until `blocked` has fired. + type InFlightSuspend = { + gateId: string; + deferredClear: { reason: "resolved" | "timeout" | "shutdown" } | null; + }; + let suspendingGate: InFlightSuspend | null = null; + + // Callback the gate manager invokes when a gate resolves, times out, or is + // shut down. Refreshes the snapshot and drives the loop's next step. + // + // A parked ask-flow approval that TIMES OUT ends without running its tool: + // it must be answered with a synthetic error result rather than left as a + // dangling tool_use. That path enqueues `resume.tool_result` INSTEAD OF + // `reactor.gate.cleared` — the two are mutually exclusive, because enqueuing + // both would drive two re-inferences for one timeout. Every other case (an + // async-marker pending op with no suspendedCall, no pending op at all, a + // `resolved`/`shutdown` reason, or a shutting-down reactor) keeps today's + // behavior: enqueue `reactor.gate.cleared` and let the director re-infer. + // + // A delivered `resolved` never reaches here on the ask rail — the redispatch + // and reject paths clear the gate silently (no onCleared) — so the timeout + // branch is gated on `reason === "timeout"` and shutdown stays on the plain + // path: a shutting-down reactor must not manufacture tool results. + function onGateCleared( + gateId: string, + reason: "resolved" | "timeout" | "shutdown", + ): void { + // A clear that fires while this gate's suspend is still committing must not + // take effect before `reactor.gate.blocked` is emitted. Record it and let + // suspendOnGate replay the full handler once the block is announced. + if ( + suspendingGate !== null && + suspendingGate.gateId === gateId && + suspendingGate.deferredClear === null + ) { + suspendingGate.deferredClear = { reason }; + return; + } + + if (stateManager !== null) { + stateManager.setGatesSnapshot(gates.snapshot()); + } + + if (reason === "timeout") { + const op = correlations.findByGateId(gateId); + if (op !== undefined && op.suspendedCall !== undefined) { + correlations.remove(op.correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(op.correlationId); + } + enqueue({ + type: "resume.tool_result", + result: { + callId: op.suspendedCall.id, + content: "approval timed out", + isError: true, + }, + }); + return; + } + } + + emit({ + type: "reactor.gate.cleared", + seq: nextSeq(), + data: { gateId, reason }, + }); + enqueue({ type: "reactor.gate.cleared", gateId, reason }); + } + + // Parks the reactor on a gate. Shared by the director's `suspend` action and + // the before-tool `suspend` decision so both paths register the gate, + // durably persist any pending operation, snapshot the active gates, and + // commit before returning to the loop — a suspended reactor's state must be + // durable across restart. When `pendingOp` is supplied its correlation is + // registered and it is persisted; the director path has already persisted + // its pending operation (via the tool's pending marker), so it passes none. + async function suspendOnGate(args: { + gateType: GateType; + gateId: string; + timeoutMs: number; + correlationId: string | undefined; + pendingOp: PendingOperation | undefined; + }): Promise { + const { gateType, gateId, timeoutMs, correlationId, pendingOp } = args; + + if (pendingOp !== undefined) { + correlations.register(pendingOp); + if (stateManager !== null) { + stateManager.addPendingOperation(pendingOp); + } + } + + // Track this suspend as in flight so a clear racing the commit below is + // deferred until `reactor.gate.blocked` has been emitted. + const inFlightSuspend: InFlightSuspend = { gateId, deferredClear: null }; + suspendingGate = inFlightSuspend; + + // Register the gate. onGateCleared enqueues the cleared event so the loop + // processes it normally without blocking here. + void gates.register( + gateId, + gateType, + timeoutMs, + correlationId, + onGateCleared, + ); + + if (stateManager !== null) { + stateManager.setGatesSnapshot(gates.snapshot()); + } + + // Registering the gate (and any pending operation) is a durable state + // change that must be committed even if this cycle did no other work. + cycleSuspended = true; + + // Commit before the loop continues so the suspended state is durable + // across restart. + await commitCycle(); + + // Emit `reactor.gate.blocked` only AFTER the commit. This event resolves + // the `send()` awaiter as "suspended", and a downstream consumer (the warm + // agent's run-boundary durability mirror) reads the pending operation back + // out of the just-committed context store the instant `send()` settles. + // Emitting before the commit would resolve `send()` first, letting that + // mirror read a store that has not yet persisted the pending op -- it would + // durably mirror an empty pending-operation set and lose the approval + // snapshot, so a parked correlation could not be re-registered after a hub + // reconnect. This upholds persist-before-settle: the durable commit the + // header promises before returning to the loop lands before the suspension + // settles. + emit({ + type: "reactor.gate.blocked", + seq: nextSeq(), + data: { + reason: gateType, + gateId, + ...(correlationId !== undefined ? { correlationId } : {}), + ...(pendingOp?.approvalSnapshot !== undefined + ? { approvalSnapshot: pendingOp.approvalSnapshot } + : {}), + }, + }); + + // The suspension is announced. If the gate cleared while the commit was in + // flight, its handler was deferred to keep it after `blocked`; replay it + // now, in order. + suspendingGate = null; + if (inFlightSuspend.deferredClear !== null) { + onGateCleared(gateId, inFlightSuspend.deferredClear.reason); + } + } + + // Re-registers a live gate and correlation for each pending operation loaded + // from the context store on restart. The remaining timeout is computed from + // the persisted absolute deadline (`timeoutAt`) against the current clock, so + // the deadline is preserved across the restart rather than restarted; a + // deadline already in the past clamps to 1ms so the gate fires on the next + // tick. An operation persisted without a `timeoutAt` (hold-indefinitely) has + // no deadline to preserve; the gate manager cannot express an indefinite + // hold, so it is armed with the session-level `gateTimeout` — the same + // effective timeout the director-suspend fallback uses — rather than a + // silent zero. This does not run through `suspendOnGate`: rehydration must + // not re-emit `reactor.gate.blocked` (the suspension already happened before + // the restart) and must not commit (nothing changed). + function rehydrateGates(ops: PendingOperation[]): void { + for (const op of ops) { + const timeoutMs = + op.timeoutAt !== undefined + ? Math.max(1, op.timeoutAt - Date.now()) + : gateTimeout; + correlations.register(op); + void gates.register( + op.gateId, + signalKindToGateType(op.kind), + timeoutMs, + op.correlationId, + onGateCleared, + ); + } + } + // ------------------------------------------------------------------------- // Main loop // ------------------------------------------------------------------------- @@ -938,6 +1278,18 @@ export function createReactor(config: ReactorConfig): Reactor { openMessageRun(event.message.headers.messageId); } + // A parked approval that ended without running its tool (rejected or + // timed out) carries a synthetic error result answering the parked call. + // Land it in history before the director decides so the tool_result turn + // closes the dangling tool_use and the re-inference the director returns + // sees a well-formed sequence. No tool ran, so no tool.done and no + // counter change accompany it. + if (event.type === "resume.tool_result") { + if (stateManager !== null) { + stateManager.appendTurn(createToolResultTurn([event.result])); + } + } + let actions; try { actions = await director.decide( @@ -1030,46 +1382,13 @@ export function createReactor(config: ReactorConfig): Reactor { const suspendAction = normalized.find((a) => a.type === "suspend"); if (suspendAction !== undefined && suspendAction.type === "suspend") { const { gate } = suspendAction; - const effectiveTimeout = - gate.timeoutMs > 0 ? gate.timeoutMs : gateTimeout; - - emit({ - type: "reactor.gate.blocked", - seq: nextSeq(), - data: { reason: gate.type, gateId: gate.gateId }, + await suspendOnGate({ + gateType: gate.type, + gateId: gate.gateId, + timeoutMs: gate.timeoutMs > 0 ? gate.timeoutMs : gateTimeout, + correlationId: gate.correlationId, + pendingOp: undefined, }); - - if (stateManager !== null) { - stateManager.setGatesSnapshot(gates.snapshot()); - } - - // Register the gate. The onCleared callback enqueues the cleared event - // so the loop processes it normally without blocking here. - void gates.register( - gate.gateId, - gate.type, - effectiveTimeout, - gate.correlationId, - (gateId, reason) => { - if (stateManager !== null) { - stateManager.setGatesSnapshot(gates.snapshot()); - } - emit({ - type: "reactor.gate.cleared", - seq: nextSeq(), - data: { gateId, reason }, - }); - enqueue({ type: "reactor.gate.cleared", gateId, reason }); - }, - ); - - if (stateManager !== null) { - stateManager.setGatesSnapshot(gates.snapshot()); - } - - // Commit before the loop continues so the suspended-state turns are - // durable across restart. - await commitCycle(); continue; } @@ -1131,16 +1450,6 @@ export function createReactor(config: ReactorConfig): Reactor { const parallel = toolsAction.parallel !== false; const addToHistory = toolsAction.addToHistory !== false; await executeTools(toolsAction.calls, parallel, addToHistory); - // Checkpoint the completed tool cycle (the assistant tool_call turn plus - // its results) so an interrupt that rebuilds the agent from the store - // reloads the full exchange. Otherwise context commits only at cycle - // terminals and an uncommitted tool turn vanishes on rebuild. Guarded on - // addToHistory: only then does history end with the tool_result turn, so - // the persisted prefix is well-formed rather than an assistant turn with - // unanswered tool calls. - if (addToHistory) { - await commitCycle(); - } continue; } @@ -1164,6 +1473,8 @@ export function createReactor(config: ReactorConfig): Reactor { // whose history has not changed since this revision skips writeTurns rather // than re-serializing the entire (potentially large) conversation and its // historical tool-output blobs. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts let lastWrittenTurnsRevision = 0; async function initiateShutdown(): Promise { @@ -1238,11 +1549,28 @@ export function createReactor(config: ReactorConfig): Reactor { initialOps, initialUsage, ); - stateManager.setGatesSnapshot(gates.snapshot()); - - emit({ type: "reactor.start", seq: nextSeq(), data: {} }); try { + // Re-arm gates for operations that were suspended before the restart. + // The state manager holds the loaded pending operations, but a gate is + // in-memory and does not survive a restart; without this a reloaded + // suspended agent is wedged (no live gate to clear, no correlation to + // match). Each op re-registers its correlation and a live gate keyed on + // the op's own gateId and correlationId, so a delivered signal clears + // it exactly as the original suspension would have. + // + // Rehydration runs inside this try/catch because the pending operations + // come from the context store — an untrusted external boundary — and + // correlation/gate registration throws synchronously on a duplicate + // correlationId or gateId. A throw must surface as reactor.error plus + // reactor.done (matching the load-failure path), not brick the reactor + // as a silent unhandled rejection. + rehydrateGates(initialOps); + + stateManager.setGatesSnapshot(gates.snapshot()); + + emit({ type: "reactor.start", seq: nextSeq(), data: {} }); + await loop(); } catch (cause) { const msg = cause instanceof Error ? cause.message : String(cause); @@ -1262,23 +1590,34 @@ export function createReactor(config: ReactorConfig): Reactor { function deliver(message: InboundMessage): void { if (done) return; void (async () => { + let correlated: boolean; try { - const correlated = await tryCorrelate(message); - if (!correlated) { - emit({ - type: "message.received", - seq: nextSeq(), - data: { message }, - }); - enqueue({ type: "message.received", message }); - } + correlated = await tryCorrelate(message); } catch (cause) { - // Without this the throw becomes an unhandled rejection and the inbound - // message is silently dropped. Surface it so the failure is observable - // instead of the message vanishing. + // A correlation-path invariant failed (e.g. a malformed approval + // decision). Surface it as a fatal reactor error rather than a silent + // unhandled rejection, and stop the run — the resume cannot proceed on + // a decision the reactor cannot trust. const msg = cause instanceof Error ? cause.message : String(cause); - logger.error`Failed to deliver inbound message: ${cause}`; - emitError(`Failed to deliver inbound message: ${msg}`, false); + logger.error`Correlation dispatch failed: ${cause}`; + emitError(`Correlation dispatch failed: ${msg}`, true); + closeMessageRun("failed", { + message: `Correlation dispatch failed: ${msg}`, + kind: "reactor_fatal", + }); + done = true; + if (!shutdownStarted) { + await initiateShutdown(); + } + return; + } + if (!correlated) { + emit({ + type: "message.received", + seq: nextSeq(), + data: { message }, + }); + enqueue({ type: "message.received", message }); } })(); } diff --git a/vendor/intx-inference/src/sse.test.ts b/vendor/intx-inference/src/sse.test.ts index 8bcd0e5ab..b55b8a1ae 100644 --- a/vendor/intx-inference/src/sse.test.ts +++ b/vendor/intx-inference/src/sse.test.ts @@ -30,22 +30,6 @@ describe("parseSSE", () => { expect(results).toEqual(["hello"]); }); - test("aborts instead of buffering an unbounded newline-less run", async () => { - // Simulates a stuck/hostile stream that dribbles bytes without ever - // sending a newline: the buffer would otherwise grow until OOM. - const encoder = new TextEncoder(); - const megabyte = "x".repeat(1024 * 1024); - const stream = new ReadableStream({ - pull(controller) { - controller.enqueue(encoder.encode(megabyte)); - }, - }); - - await expect(collectSSE(stream)).rejects.toThrow( - /exceeded .* characters without a newline/, - ); - }); - test("parses multiple data events", async () => { const stream = makeStream(["data: first\n\ndata: second\n\n"]); const results = await collectSSE(stream); diff --git a/vendor/intx-inference/src/sse.ts b/vendor/intx-inference/src/sse.ts index 77b1a24f5..601355c22 100644 --- a/vendor/intx-inference/src/sse.ts +++ b/vendor/intx-inference/src/sse.ts @@ -18,6 +18,8 @@ const decoder = new TextDecoder(); // payloads, sit far below this — and fail loudly instead of consuming all // memory. The limit is on `buffer.length` (UTF-16 code units), which bounds the // retained string regardless of the source encoding's bytes-per-character. +// +// Locally patched — see vendor/intx-inference/PATCHES.md#sse-ts const MAX_LINE_LENGTH = 16 * 1024 * 1024; export async function* parseSSE( diff --git a/vendor/intx-inference/src/state.test.ts b/vendor/intx-inference/src/state.test.ts deleted file mode 100644 index 387293f23..000000000 --- a/vendor/intx-inference/src/state.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, test, expect } from "bun:test"; - -import { createStateManager } from "./state"; - -import type { - AssistantTurn, - PendingOperation, - TokenUsage, -} from "@intx/types/runtime"; -import type { GateSnapshot } from "./gates"; - -function emptyUsage(): TokenUsage { - return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }; -} - -function makeAssistantTurn(text: string): AssistantTurn { - return { - role: "assistant", - content: [{ type: "text", text }], - model: "mock-model", - timestamp: 1000, - }; -} - -function op(id: string): PendingOperation { - return { correlationId: id, registeredAt: 1000, gateId: `g-${id}` }; -} - -function gate(id: string): GateSnapshot { - return { gateId: id, type: "approval", timeoutAt: 5000 }; -} - -describe("createStateManager snapshot", () => { - test("turns getter is lazy: reflects appends made after snapshot()", () => { - const mgr = createStateManager("s", [], [], emptyUsage()); - const snap = mgr.snapshot(); - - mgr.appendTurn(makeAssistantTurn("late")); - - expect(snap.turns).toHaveLength(1); - expect(snap.turns[0]).toMatchObject({ - content: [{ type: "text", text: "late" }], - }); - }); - - test("turns getter is memoized: repeated reads return the same array", () => { - const mgr = createStateManager( - "s", - [makeAssistantTurn("a")], - [], - emptyUsage(), - ); - const snap = mgr.snapshot(); - - expect(snap.turns).toBe(snap.turns); - }); - - test("turns is a copy with frozen elements (isolation)", () => { - const mgr = createStateManager( - "s", - [makeAssistantTurn("a")], - [], - emptyUsage(), - ); - const snap = mgr.snapshot(); - const view = snap.turns; - - view.push(makeAssistantTurn("mutant")); - expect(mgr.getTurns()).toHaveLength(1); - - expect(Object.isFrozen(snap.turns[0])).toBe(true); - }); - - test("getTurns returns a copy: mutating it does not corrupt internal state", () => { - const mgr = createStateManager( - "s", - [makeAssistantTurn("a")], - [], - emptyUsage(), - ); - const turns = mgr.getTurns(); - turns.push(makeAssistantTurn("mutant")); - expect(mgr.getTurns()).toHaveLength(1); - }); - - test("pendingOperations is point-in-time: later adds are not reflected", () => { - const mgr = createStateManager("s", [], [op("one")], emptyUsage()); - const snap = mgr.snapshot(); - - mgr.addPendingOperation(op("two")); - - expect(snap.pendingOperations).toHaveLength(1); - expect(snap.pendingOperations[0]?.correlationId).toBe("one"); - }); - - test("activeGates is point-in-time: later gate changes are not reflected", () => { - const mgr = createStateManager("s", [], [], emptyUsage()); - mgr.setGatesSnapshot([gate("g1")]); - const snap = mgr.snapshot(); - - mgr.setGatesSnapshot([gate("g1"), gate("g2")]); - - expect(snap.activeGates).toHaveLength(1); - expect(snap.activeGates[0]?.gateId).toBe("g1"); - }); - - test("activeForks is point-in-time: later forks are not reflected", () => { - const mgr = createStateManager("s", [], [], emptyUsage()); - mgr.addFork("f1", "child"); - const snap = mgr.snapshot(); - - mgr.addFork("f2", "independent"); - - expect(snap.activeForks).toHaveLength(1); - expect(snap.activeForks[0]?.forkId).toBe("f1"); - }); -}); - -describe("createStateManager turnsRevision", () => { - test("advances on appendTurn and replaceTurns, not on reads", () => { - const mgr = createStateManager("s", [], [], emptyUsage()); - expect(mgr.getTurnsRevision()).toBe(0); - - mgr.appendTurn(makeAssistantTurn("a")); - expect(mgr.getTurnsRevision()).toBe(1); - - mgr.getTurns(); - mgr.snapshot().turns; - expect(mgr.getTurnsRevision()).toBe(1); - - mgr.replaceTurns([makeAssistantTurn("b")]); - expect(mgr.getTurnsRevision()).toBe(2); - }); -}); diff --git a/vendor/intx-inference/src/state.ts b/vendor/intx-inference/src/state.ts index b6749d51e..f1d39b44c 100644 --- a/vendor/intx-inference/src/state.ts +++ b/vendor/intx-inference/src/state.ts @@ -21,6 +21,8 @@ export type ReactorStateManager = ReturnType; * Recursively freezes a turn so snapshots can share its reference instead of * deep-cloning the whole history on every director decision. Freezing costs * O(turn size) once at append; cloning cost O(total history) per snapshot. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#state-ts */ function deepFreeze(value: T): T { if (value === null || typeof value !== "object" || Object.isFrozen(value)) { diff --git a/vendor/intx-inference/src/transform.test.ts b/vendor/intx-inference/src/transform.test.ts index fd1ee1f94..75fa0ff0d 100644 --- a/vendor/intx-inference/src/transform.test.ts +++ b/vendor/intx-inference/src/transform.test.ts @@ -27,6 +27,26 @@ describe("transformMessages", () => { expect(firstMsg?.content[0]?.type).toBe("thinking"); }); + test("rewrites safety_rating blocks to text for cross-provider history", () => { + const messages: ConversationTurn[] = [ + { + role: "assistant", + model: "gemini-2.5-flash", + content: [{ type: "safety_rating", blockReason: "PROHIBITED_CONTENT" }], + timestamp: 1000, + }, + ]; + + const result = transformMessages(messages, { + targetModel: "claude-sonnet-5", + }); + + expect(result).toHaveLength(1); + expect(result[0]?.content).toEqual([ + { type: "text", text: "Request blocked: PROHIBITED_CONTENT" }, + ]); + }); + test("strips thinking blocks when replaying to a different model", () => { const messages: ConversationTurn[] = [ { @@ -41,7 +61,7 @@ describe("transformMessages", () => { ]; const result = transformMessages(messages, { - targetModel: "gpt-4o", + targetModel: "gpt-5.5", }); expect(result).toHaveLength(1); @@ -95,7 +115,7 @@ describe("transformMessages", () => { // No tool result follows — the conversation was interrupted. ]; - const result = transformMessages(messages, { targetModel: "gpt-4o" }); + const result = transformMessages(messages, { targetModel: "gpt-5.5" }); // Should inject a synthetic tool result message. expect(result).toHaveLength(3); @@ -141,7 +161,7 @@ describe("transformMessages", () => { }, ]; - const result = transformMessages(messages, { targetModel: "gpt-4o" }); + const result = transformMessages(messages, { targetModel: "gpt-5.5" }); expect(result).toHaveLength(3); }); @@ -159,7 +179,7 @@ describe("transformMessages", () => { }, ]; - const result = transformMessages(messages, { targetModel: "gpt-4o" }); + const result = transformMessages(messages, { targetModel: "gpt-5.5" }); expect(result).toHaveLength(2); expect(result[0]).toEqual(messages[0]); expect(result[1]).toEqual(messages[1]); diff --git a/vendor/intx-inference/src/transform.ts b/vendor/intx-inference/src/transform.ts index 40cc3c50e..3b820413e 100644 --- a/vendor/intx-inference/src/transform.ts +++ b/vendor/intx-inference/src/transform.ts @@ -5,11 +5,15 @@ // calls receive synthetic error results, and tool call IDs are normalized to // a portable format. // -// Transformation runs automatically when the target model differs from a -// message's originating model. The originating model is tracked per-message, -// not per-conversation. +// Callers invoke transformMessages when switching models. Adapter +// buildRequest paths also apply provider-specific history fixes. The +// originating model is tracked per-message, not per-conversation. -import type { ConversationTurn, ContentBlock } from "@intx/types/runtime"; +import { + formatSafetyRatingText, + type ConversationTurn, + type ContentBlock, +} from "@intx/types/runtime"; export type TransformOptions = { targetModel: string; @@ -31,12 +35,26 @@ export function transformMessages( const isSameModel = msg.model === targetModel; const keepThinking = keepThinkingForSameModel && isSameModel; - const filteredContent = msg.content.filter((block) => { - if (block.type === "thinking") { - return keepThinking; - } - return true; - }); + const filteredContent = msg.content + .filter((block) => { + if (block.type === "thinking") { + return keepThinking; + } + return true; + }) + // safety_rating is output-only metadata. Convert it to text + // so cross-provider history keeps role alternation and a + // human-readable block reason without requiring every + // adapter to special-case the block. + .map((block): ContentBlock => { + if (block.type === "safety_rating") { + return { + type: "text", + text: formatSafetyRatingText(block), + }; + } + return block; + }); // Filter out assistant messages that have no text or tool calls // (aborted/error messages with only thinking blocks removed). diff --git a/vendor/intx-storage-isogit/LICENSE b/vendor/intx-storage-isogit/LICENSE new file mode 100644 index 000000000..c6487f4fd --- /dev/null +++ b/vendor/intx-storage-isogit/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-storage-isogit/README.md b/vendor/intx-storage-isogit/README.md new file mode 100644 index 000000000..04998ef47 --- /dev/null +++ b/vendor/intx-storage-isogit/README.md @@ -0,0 +1,28 @@ +# @intx/storage-isogit + +Isomorphic-git backed implementation of `ContextStore` and +`AuditStore`. Each agent gets its own git repository on disk; +inference state lives on a working branch, the tool-authorization +audit log lives on its own branch, and mail history lives in a +dedicated audit store that commits each inbound and outbound +message. + +Consumed by `@intx/agent` for in-process persistence, and by +`@intx/hub-sessions` and `@intx/hub-agent` for the agent +repositories that move between the hub and the sidecar as packs. + +```ts +import { createIsogitStore } from "@intx/storage-isogit"; + +const store = await createIsogitStore("./tmp/agent-repo", signer); + +// store implements both ContextStore and AuditStore -- hand it to +// the inference and tool layers as appropriate. +``` + +The pack-send and pack-receive helpers (`createDeployPack`, +`createNegotiatedPack`, `applyPack`, `receivePackObjects`) produce +and consume the wire bytes that `@intx/pack-transport` chunks +across the WebSocket. A `CommitSigner` is optional but required +when the consumer needs every commit to carry a verifiable +signature. diff --git a/vendor/intx-storage-isogit/package.json b/vendor/intx-storage-isogit/package.json new file mode 100644 index 000000000..738200a11 --- /dev/null +++ b/vendor/intx-storage-isogit/package.json @@ -0,0 +1,19 @@ +{ + "name": "@intx/storage-isogit", + "version": "0.2.2", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@intx/log": "0.2.2", + "@intx/mime": "0.2.2", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "isomorphic-git": "catalog:" + } +} diff --git a/vendor/intx-storage-isogit/src/commit-helpers.ts b/vendor/intx-storage-isogit/src/commit-helpers.ts new file mode 100644 index 000000000..b837c7465 --- /dev/null +++ b/vendor/intx-storage-isogit/src/commit-helpers.ts @@ -0,0 +1,16 @@ +import type { CommitSigner } from "./signer"; + +export type SigningArgs = { + onSign?: (args: { payload: string }) => Promise<{ signature: string }>; + signingKey?: string; +}; + +export function buildSigningArgs( + signer: CommitSigner | undefined, +): SigningArgs { + if (signer === undefined) return {}; + return { + signingKey: "sshsig", + onSign: async ({ payload }) => ({ signature: await signer(payload) }), + }; +} diff --git a/vendor/intx-storage-isogit/src/gc-cache-transparency.test.ts b/vendor/intx-storage-isogit/src/gc-cache-transparency.test.ts new file mode 100644 index 000000000..321f26733 --- /dev/null +++ b/vendor/intx-storage-isogit/src/gc-cache-transparency.test.ts @@ -0,0 +1,193 @@ +// The store threads one long-lived isomorphic-git `cache` object per repo +// through every git.* call. GC repacks the object store — consolidating +// loose objects into a fresh, differently-named pack and pruning the old +// one — behind that cache's back. These tests pin that the cache stays a +// pure accelerator across a repack: an object that migrates from a pruned +// pack into the new one still reads correctly under the SAME warm cache +// (because reads enumerate .idx files from disk, so a stranded parse of a +// pruned pack is never consulted), and an object GC genuinely drops +// surfaces NotFound rather than stale bytes. + +import { describe, test, expect, afterEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import git from "isomorphic-git"; +import { initAgentRepo } from "./init"; +import { runGC } from "./gc"; + +const author = { name: "Test", email: "test@test.dev" }; +const tempDirs: string[] = []; + +async function tempDir(): Promise { + const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gc-cache-")); + tempDirs.push(d); + return d; +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })), + ); +}); + +function listIdx(dir: string): string[] { + const p = path.join(dir, ".git", "objects", "pack"); + if (!fs.existsSync(p)) return []; + return fs.readdirSync(p).filter((x) => x.endsWith(".idx")); +} + +function countLoose(dir: string): number { + const base = path.join(dir, ".git", "objects"); + if (!fs.existsSync(base)) return 0; + let n = 0; + for (const d of fs.readdirSync(base)) { + if (!/^[0-9a-f]{2}$/.test(d)) continue; + n += fs.readdirSync(path.join(base, d)).length; + } + return n; +} + +async function commitFile( + dir: string, + name: string, + content: string, +): Promise { + const full = path.join(dir, name); + await fs.promises.mkdir(path.dirname(full), { recursive: true }); + await fs.promises.writeFile(full, content); + await git.add({ fs, dir, filepath: name }); + return git.commit({ fs, dir, message: `add ${name}`, author }); +} + +async function commitFileOnRef( + dir: string, + branch: string, + name: string, + content: string, +): Promise { + await git.checkout({ fs, dir, ref: branch }); + const oid = await commitFile(dir, name, content); + await git.checkout({ fs, dir, ref: "main" }); + return oid; +} + +function packfileCacheKeys(cache: object): string[] { + const packSym = Object.getOwnPropertySymbols(cache).find( + (s) => s.toString() === "Symbol(PackfileCache)", + ); + if (packSym === undefined) return []; + const map: unknown = Reflect.get(cache, packSym); + if (!(map instanceof Map)) return []; + const keys: string[] = []; + for (const k of map.keys()) { + if (typeof k === "string") keys.push(k); + } + return keys; +} + +describe("shared-cache repack transparency", () => { + test("object in a pre-GC pack reads correctly after GC prunes that pack, under the SAME warm cache", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + const targetContent = "the-target-blob-payload-v1"; + let tip = await commitFile(dir, "state/target.txt", targetContent); + const targetOid = await git.writeBlob({ + fs, + dir, + blob: Buffer.from(targetContent), + }); + for (let i = 0; i < 5; i++) { + tip = await commitFile(dir, `state/f${i}.txt`, `payload ${i}`); + } + + // First GC consolidates all loose objects into pack #1 and prunes the + // loose copies. The target now lives only in pack #1. + await runGC(dir, { retention: "keep-history" }); + const idxBefore = listIdx(dir); + const firstIdx = idxBefore[0]; + if (firstIdx === undefined) throw new Error("expected a pack .idx"); + expect(countLoose(dir)).toBe(0); + + // One shared, long-lived cache object — the exact shape the store + // threads through every git.* call. + const cache: object = {}; + + // Warm the cache: read the target from the pre-GC pack, populating + // isomorphic-git's PackfileCache keyed by the pre-GC .idx filename. + const warm = await git.readBlob({ fs, dir, oid: targetOid, cache }); + expect(Buffer.from(warm.blob).toString()).toBe(targetContent); + expect(packfileCacheKeys(cache).some((k) => k.includes(firstIdx))).toBe( + true, + ); + + // More commits, then a second GC: repacks into a new pack #2 that + // supersedes and prunes pack #1. The target migrates to pack #2. + for (let i = 0; i < 4; i++) { + tip = await commitFile(dir, `state/g${i}.txt`, `more ${i}`); + } + await runGC(dir, { retention: "keep-history" }); + + const idxAfter = listIdx(dir); + // The pre-GC pack is gone from disk, but its parsed index is still + // stranded in the warm cache map. + expect(idxAfter).not.toContain(firstIdx); + expect(packfileCacheKeys(cache).some((k) => k.includes(firstIdx))).toBe( + true, + ); + + // The test: read the target again under the SAME warm cache, after the + // pack the cache indexed was pruned from disk. + const reread = await git.readBlob({ fs, dir, oid: targetOid, cache }); + expect(Buffer.from(reread.blob).toString()).toBe(targetContent); + + // Exercise the readTree/readCommit path through the same cache too. + const co = await git.readCommit({ fs, dir, oid: tip, cache }); + const tr = await git.readTree({ fs, dir, oid: co.commit.tree, cache }); + expect(tr.tree.length).toBeGreaterThan(0); + }); + + test("GC that drops a now-unreachable object surfaces NotFound, not stale bytes, under a warm cache", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + for (let i = 0; i < 3; i++) { + await commitFile(dir, `state/f${i}.txt`, `keep ${i}`); + } + + // A blob reachable only from a secondary ref. + const orphanContent = "orphan-only-in-old-pack"; + const orphanOid = await git.writeBlob({ + fs, + dir, + blob: Buffer.from(orphanContent), + }); + await git.branch({ fs, dir, ref: "scratch" }); + await commitFileOnRef(dir, "scratch", "state/orphan.txt", orphanContent); + + // Pack everything reachable into pack #1. + await runGC(dir, { retention: "keep-history" }); + expect(countLoose(dir)).toBe(0); + + const cache: object = {}; + const warm = await git.readBlob({ fs, dir, oid: orphanOid, cache }); + expect(Buffer.from(warm.blob).toString()).toBe(orphanContent); + + // Drop the scratch ref so the orphan is unreachable, then GC. + await git.deleteBranch({ fs, dir, ref: "scratch" }); + await commitFile(dir, "state/f9.txt", "keep 9"); + await runGC(dir, { retention: "keep-history" }); + + // The object is genuinely gone — a NotFound throw, never stale bytes. + let threw = false; + try { + const r = await git.readBlob({ fs, dir, oid: orphanOid, cache }); + expect(Buffer.from(r.blob).toString()).not.toBe(orphanContent); + } catch { + threw = true; + } + expect(threw).toBe(true); + }); +}); diff --git a/vendor/intx-storage-isogit/src/gc.test.ts b/vendor/intx-storage-isogit/src/gc.test.ts new file mode 100644 index 000000000..80ec992a0 --- /dev/null +++ b/vendor/intx-storage-isogit/src/gc.test.ts @@ -0,0 +1,304 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import git from "isomorphic-git"; +import { initAgentRepo } from "./init"; +import { + applyPack, + receivePackObjects, + type CommitVerifier, + type TreeValidator, +} from "./pack-receive"; +import { collectReachableObjects } from "./object-walk"; +import { repoDiskUsage } from "./repo-disk"; +import { runGC } from "./gc"; +import { IsogitStore } from "./store"; + +const author = { name: "Test", email: "test@test.dev" }; + +const tempDirs: string[] = []; + +async function tempDir(): Promise { + const d = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "interchange-gc-test-"), + ); + tempDirs.push(d); + return d; +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })), + ); +}); + +async function packFor(sourceDir: string, oids: string[]): Promise { + const result = await git.packObjects({ + fs, + dir: sourceDir, + oids, + write: false, + }); + if (result.packfile === undefined) { + throw new Error("packObjects returned no packfile"); + } + return result.packfile; +} + +/** + * A single-commit source repo. `tipOids` is the tip-tree reachable set, the + * same shape a deploy pack carries (commit + tree + blobs, no ancestors). + */ +async function sourceRepo(files: Record): Promise<{ + dir: string; + tip: string; + tipOids: string[]; +}> { + const dir = await tempDir(); + await git.init({ fs, dir, defaultBranch: "main" }); + for (const [filepath, content] of Object.entries(files)) { + const full = path.join(dir, filepath); + await fs.promises.mkdir(path.dirname(full), { recursive: true }); + await fs.promises.writeFile(full, content); + await git.add({ fs, dir, filepath }); + } + const tip = await git.commit({ fs, dir, message: "deploy", author }); + return { dir, tip, tipOids: await collectReachableObjects(dir, tip) }; +} + +/** + * Commit a file onto `refs/heads/main` in a target repo, producing loose + * objects and extending the main ancestry chain. + */ +async function commitOnMain(dir: string, name: string): Promise { + const full = path.join(dir, "state", name); + await fs.promises.mkdir(path.dirname(full), { recursive: true }); + await fs.promises.writeFile(full, `content of ${name}`); + await git.add({ fs, dir, filepath: `state/${name}` }); + return git.commit({ fs, dir, message: `add ${name}`, author }); +} + +async function deployOnto( + dir: string, + files: Record, + transferId: string, +): Promise { + const source = await sourceRepo(files); + const pack = await packFor(source.dir, source.tipOids); + await applyPack(dir, pack, "refs/heads/deploy", source.tip, transferId); + return source.tip; +} + +describe("runGC", () => { + test("reclaims disk from superseded packs and loose objects across both refs", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + let mainTip = ""; + for (const name of ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]) { + mainTip = await commitOnMain(dir, name); + } + + await deployOnto(dir, { "deploy/one.txt": "first deploy" }, "deploy-1"); + await deployOnto(dir, { "deploy/two.txt": "second deploy" }, "deploy-2"); + const deployTip = await deployOnto( + dir, + { "deploy/three.txt": "third deploy" }, + "deploy-3", + ); + + const rejectAll: TreeValidator = () => ({ ok: false, reason: "nope" }); + const rejectedSource = await sourceRepo({ "evil.txt": "bad" }); + const rejectedPack = await packFor( + rejectedSource.dir, + rejectedSource.tipOids, + ); + await expect( + receivePackObjects( + dir, + rejectedPack, + "refs/heads/main", + rejectedSource.tip, + "reject-1", + mainTip, + rejectAll, + ), + ).rejects.toThrow(/^path_violation/); + + const unsignedSource = await sourceRepo({ "deploy/x.txt": "unsigned" }); + const unsignedPack = await packFor( + unsignedSource.dir, + unsignedSource.tipOids, + ); + const verifier: CommitVerifier = async () => true; + await expect( + applyPack( + dir, + unsignedPack, + "refs/heads/deploy", + unsignedSource.tip, + "reject-2", + verifier, + ), + ).rejects.toThrow(/^signature_unsigned/); + + const before = repoDiskUsage(dir); + expect(before.packCount).toBeGreaterThan(1); + expect(before.looseObjectCount).toBeGreaterThan(0); + + const result = await runGC(dir, { retention: "keep-history" }); + + expect(result.reclaimedBytes).toBeGreaterThan(0); + expect(result.after.packCount).toBe(1); + expect(result.after.looseObjectCount).toBe(0); + + // Both diverging heads remain readable after the consolidation. + expect((await git.readCommit({ fs, dir, oid: mainTip })).oid).toBe(mainTip); + expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( + deployTip, + ); + }); + + test("preserves every object reachable from both diverging refs", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + const mainTip = await commitOnMain(dir, "main-state.txt"); + const deployTip = await deployOnto( + dir, + { "deploy/prompt.txt": "deploy content" }, + "deploy-1", + ); + + const mainBefore = new Set(await collectReachableObjects(dir, mainTip)); + const deployBefore = new Set(await collectReachableObjects(dir, deployTip)); + + await runGC(dir, { retention: "tip-only" }); + + // The full tree-reachable closure of both diverging heads survives the + // consolidation; walking it re-reads every commit and tree, and the + // enumerated object set is unchanged. + expect(new Set(await collectReachableObjects(dir, mainTip))).toEqual( + mainBefore, + ); + expect(new Set(await collectReachableObjects(dir, deployTip))).toEqual( + deployBefore, + ); + }); + + test("tip-only drops history while keep-history retains it", async () => { + async function buildChain(): Promise<{ + dir: string; + ancestor: string; + tip: string; + }> { + const dir = await tempDir(); + await initAgentRepo(dir); + const ancestor = await commitOnMain(dir, "old.txt"); + const tip = await commitOnMain(dir, "new.txt"); + return { dir, ancestor, tip }; + } + + const shallow = await buildChain(); + await runGC(shallow.dir, { retention: "tip-only" }); + expect( + (await git.readCommit({ fs, dir: shallow.dir, oid: shallow.tip })).oid, + ).toBe(shallow.tip); + await expect( + git.readCommit({ fs, dir: shallow.dir, oid: shallow.ancestor }), + ).rejects.toThrow(shallow.ancestor); + + const deep = await buildChain(); + await runGC(deep.dir, { retention: "keep-history" }); + expect( + (await git.readCommit({ fs, dir: deep.dir, oid: deep.ancestor })).oid, + ).toBe(deep.ancestor); + }); + + test("keep-history tolerates dangling parents on the deploy ref", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + // A two-commit source whose tip carries a parent pointer; the pack only + // contains the tip's tree-reachable objects, so the parent is absent on + // disk once applied — the steady state of a real deploy ref. + const source = await tempDir(); + await git.init({ fs, dir: source, defaultBranch: "main" }); + await fs.promises.writeFile(path.join(source, "a.txt"), "a"); + await git.add({ fs, dir: source, filepath: "a.txt" }); + await git.commit({ fs, dir: source, message: "first", author }); + await fs.promises.writeFile(path.join(source, "b.txt"), "b"); + await git.add({ fs, dir: source, filepath: "b.txt" }); + const deployTip = await git.commit({ + fs, + dir: source, + message: "second", + author, + }); + const tipOnly = await collectReachableObjects(source, deployTip); + const pack = await packFor(source, tipOnly); + await applyPack(dir, pack, "refs/heads/deploy", deployTip, "deploy-1"); + + // The tip's parent is unresolvable; keep-history must not abort on it. + const result = await runGC(dir, { retention: "keep-history" }); + expect(result.after.packCount).toBe(1); + expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( + deployTip, + ); + }); + + test("serializes concurrent commits against GC without corruption", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + const store = new IsogitStore(dir); + + // Fire context commits and GC passes against the same repo concurrently. + // Every commit and every GC acquires the per-directory lock, so they run + // one-at-a-time and the object store is never observed mid-mutation. + const ops: Promise[] = []; + for (let i = 0; i < 12; i += 1) { + await fs.promises.writeFile( + path.join(dir, "turns.jsonl"), + `${JSON.stringify({ role: "user", content: [], timestamp: i })}\n`, + ); + ops.push(store.commit({ message: `cycle ${i.toString()}` })); + ops.push(runGC(dir, { retention: "tip-only" })); + } + await Promise.all(ops); + + // The repo is intact: HEAD resolves and walking its entire tree-reachable + // closure re-reads every commit and tree without a missing object. + const head = await git.resolveRef({ fs, dir, ref: "refs/heads/main" }); + const reachable = await collectReachableObjects(dir, head); + expect(reachable.length).toBeGreaterThan(0); + }); + + test("history reads degrade to the surviving slice after a tip-only GC", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + const store = new IsogitStore(dir); + + for (let i = 0; i < 4; i += 1) { + await fs.promises.writeFile( + path.join(dir, "turns.jsonl"), + `${JSON.stringify({ role: "user", content: [], timestamp: i })}\n`, + ); + await fs.promises.writeFile(path.join(dir, "manifest.jsonl"), ""); + await store.commit({ message: `cycle ${i.toString()}` }); + } + const before = await store.log(10); + expect(before.length).toBeGreaterThan(1); + + await runGC(dir, { retention: "tip-only" }); + + // tip-only prunes the older commits. The reads must not throw into the + // caller (the agent's checkpoints tool) — they return the surviving + // slice from the tip, which is itself intact. + const after = await store.log(10); + expect(after.length).toBeGreaterThanOrEqual(1); + expect(after[0]?.hash).toBe(before[0]?.hash); + await store.readManifestHistory(10); + }); +}); diff --git a/vendor/intx-storage-isogit/src/gc.ts b/vendor/intx-storage-isogit/src/gc.ts new file mode 100644 index 000000000..e5d3d4d91 --- /dev/null +++ b/vendor/intx-storage-isogit/src/gc.ts @@ -0,0 +1,310 @@ +import fs from "node:fs"; +import path from "node:path"; +import git from "isomorphic-git"; +import { getLogger } from "@intx/log"; +import { collectReachableObjects } from "./object-walk"; +import { publishPackAtomically } from "./pack-receive"; +import { + gitBytes, + listRepoRefs, + repoDiskUsage, + repoObjectCounts, + type RepoDiskUsage, +} from "./repo-disk"; +import { withRepoDirLock } from "./repo-lock"; + +const logger = getLogger(["interchange", "storage-isogit", "gc"]); + +/** + * How much commit history a GC pass preserves. + * + * - `tip-only`: keep only the objects reachable from each ref tip's tree. + * Prior commits are dropped, leaving the tip commit with dangling parent + * pointers (the same shape the deploy ref already has). Smallest repo; + * suited to environments that treat the repo as current-state cache. + * - `keep-history`: also keep every object reachable through the commit + * ancestry. Preserves the audit trail; suited to environments that treat + * the repo as a long-term archive. + */ +export type RetentionPolicy = "tip-only" | "keep-history"; + +export type GCResult = { + before: RepoDiskUsage; + after: RepoDiskUsage; + reclaimedBytes: number; + keptObjects: number; +}; + +/** + * Write-path reclaim policy. A writer holding the per-directory lock samples + * the repo's object counts after its mutation and repacks under `retention` + * once the pack count reaches `packThreshold` OR the loose-object count + * reaches `looseThreshold`. Both triggers matter: a hub repo accumulates + * packs as it receives state, while a sidecar repo accumulates loose objects + * as the reactor commits. When a reclaim runs, the `.git` byte size is + * checked against `warnBytes` and a disk-pressure warning is emitted if it + * is reached — surfacing runaway accumulation that survives a reclaim — so + * the byte check rides the reclaim rather than every write. + */ +export type GCPolicy = { + packThreshold: number; + looseThreshold: number; + warnBytes: number; + retention: RetentionPolicy; +}; + +/** + * Collect every object reachable through a commit's ancestry, tolerating + * commits whose parents are not present on disk. + * + * The deploy ref is applied tip-only, so its commit carries parent pointers + * to objects that were never transferred. Reading an absent parent throws + * `NotFoundError`; we stop descending that branch rather than aborting the + * whole GC, because a missing parent is the expected steady state for these + * repos. Only that specific absence is tolerated — any other read failure + * (corruption, a non-commit oid, I/O) surfaces, since swallowing it here + * would drop a present, reachable subtree from the keep set and the caller + * would then delete it. + */ +async function collectHistoryObjects( + dir: string, + tipOid: string, +): Promise> { + const objects = new Set(); + const seenCommits = new Set(); + const queue: string[] = [tipOid]; + + while (queue.length > 0) { + const commitOid = queue.shift(); + if (commitOid === undefined) break; + if (seenCommits.has(commitOid)) continue; + seenCommits.add(commitOid); + + let parents: string[]; + try { + const { commit } = await git.readCommit({ fs, dir, oid: commitOid }); + parents = commit.parent; + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "NotFoundError") + continue; + throw err; + } + + for (const oid of await collectReachableObjects(dir, commitOid)) { + objects.add(oid); + } + for (const parent of parents) { + if (!seenCommits.has(parent)) queue.push(parent); + } + } + + return objects; +} + +/** + * Absolute paths of every `.pack` and `.idx` file currently in the repo's + * pack directory. Snapshotted before the consolidated pack is published so + * the freshly published pair is never in the removal set. + */ +function listPackFiles(dir: string): string[] { + const packDir = path.join(dir, ".git", "objects", "pack"); + let entries: string[]; + try { + entries = fs.readdirSync(packDir); + } catch (cause) { + if ( + cause instanceof Error && + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + return []; + } + throw cause; + } + return entries + .filter((name) => name.endsWith(".pack") || name.endsWith(".idx")) + .map((name) => path.join(packDir, name)); +} + +/** + * Remove every loose object fan-out directory under `.git/objects/`. + * + * Safe only after the consolidated pack containing the entire keep set is + * published: every kept object that was loose is then also packed, and every + * loose object outside the keep set is garbage. The `pack` and `info` + * children are left untouched. + */ +async function removeLooseObjects(dir: string): Promise { + const objectsDir = path.join(dir, ".git", "objects"); + let entries: string[]; + try { + entries = fs.readdirSync(objectsDir); + } catch (cause) { + if ( + cause instanceof Error && + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + return; + } + throw cause; + } + for (const name of entries) { + if (name === "pack" || name === "info") continue; + if (!/^[0-9a-f]{2}$/.test(name)) continue; + await fs.promises.rm(path.join(objectsDir, name), { + recursive: true, + force: true, + }); + } +} + +/** + * Reclaim disk in an agent git repo by repacking everything reachable from + * its refs into a single pack and dropping the superseded packs and loose + * objects. + * + * Compute the keep set as the union of reachability over every head ref + * (agent repos carry two diverging heads, `main` and `deploy`, so unioning + * is mandatory — repacking one ref's reachability alone would discard the + * other's live objects). Pack the keep set into one self-contained pack via + * `git.packObjects` and publish it through the same atomic staging dance + * receives use, so a concurrent unlocked reader never observes a torn pack. + * Only then remove the packs that predated this pass and every loose object; + * each kept object is by then present in the consolidated pack. + * + * # Concurrency + * + * The caller MUST already hold the repo's per-directory lock + * (`withRepoDirLock`). This is the lock-free core: writers trigger reclaim + * inline after a commit/apply while still holding that lock, and external + * callers not already under it use {@link runGC}, which acquires it. The + * lock excludes concurrent writers, so the keep set computed from the refs + * cannot be invalidated by a commit landing mid-pass. Removal of a + * superseded pack or loose object races only with unlocked readers, the same + * POSIX window `unpublishPack` already accepts — and strictly safer here, + * since every removed-but-reachable object is also in the freshly published + * consolidated pack. + * + * Returns the disk usage before and after plus the reclaimed byte delta. A + * repo with no resolvable refs is left untouched. Exported for use within + * the storage package only — it is intentionally absent from the package's + * public barrel. + */ +export async function gcUnderLock( + dir: string, + opts: { retention: RetentionPolicy }, +): Promise { + const before = repoDiskUsage(dir); + const refs = await listRepoRefs(dir); + + if (refs.length === 0) { + return { before, after: before, reclaimedBytes: 0, keptObjects: 0 }; + } + + const keep = new Set(); + for (const { oid } of refs) { + const reachable = + opts.retention === "tip-only" + ? await collectReachableObjects(dir, oid) + : await collectHistoryObjects(dir, oid); + for (const objectOid of reachable) keep.add(objectOid); + } + + const supersededPacks = listPackFiles(dir); + + const result = await git.packObjects({ + fs, + dir, + oids: [...keep], + write: false, + }); + if (result.packfile === undefined) { + throw new Error( + `packObjects returned no packfile while consolidating ${keep.size.toString()} objects in ${dir}`, + ); + } + const transferId = `gc-${crypto.randomUUID().replace(/-/g, "")}`; + await publishPackAtomically(dir, result.packfile, transferId); + + for (const packFile of supersededPacks) { + await fs.promises.rm(packFile, { force: true }); + } + await removeLooseObjects(dir); + + const after = repoDiskUsage(dir); + return { + before, + after, + reclaimedBytes: before.gitBytes - after.gitBytes, + keptObjects: keep.size, + }; +} + +/** + * Garbage-collect the agent repo at `dir`, acquiring the repo's + * per-directory lock for the duration. Use this from callers that are not + * already holding the lock (e.g. the hub's substrate, which holds its own + * higher-level lock but not the storage lock). Writers that trigger reclaim + * while already under the lock call {@link gcUnderLock} directly. + */ +export async function runGC( + dir: string, + opts: { retention: RetentionPolicy }, +): Promise { + return withRepoDirLock(dir, () => gcUnderLock(dir, opts)); +} + +function warnIfOverBudget(dir: string, bytes: number, warnBytes: number): void { + if (bytes >= warnBytes) { + logger.warn`disk pressure on ${dir}: .git is ${String(bytes)} bytes, at or above the ${String(warnBytes)} byte threshold`; + } +} + +/** + * Apply a write-path reclaim policy to the repo at `dir`. Reclaims when the + * pack count or the loose-object count has reached its threshold. Intended + * to be called by a writer that has just mutated the repo and is still + * holding the per-directory lock, so the reclaim itself runs without + * re-entering the lock. + * + * The trigger samples only the object counts — two directory reads — on + * every write; the full `.git` byte walk that feeds the disk-pressure + * warning runs only when a reclaim does (the collector computes it for its + * before/after delta anyway, and the failure path walks it once). So the + * warning is evaluated at reclaim time, not on every write, and the common + * below-threshold write pays no byte walk. + * + * A reclaim failure is logged, not propagated: the write that triggered this + * has already committed, so failing the caller would falsely report the + * write as failed. The disk-pressure warning still fires on a failed reclaim + * — the case where accumulation is most likely runaway. + */ +export async function maybeGCUnderLock( + dir: string, + policy: GCPolicy, +): Promise { + const counts = repoObjectCounts(dir); + if ( + counts.packCount < policy.packThreshold && + counts.looseObjectCount < policy.looseThreshold + ) { + return; + } + try { + const result = await gcUnderLock(dir, { retention: policy.retention }); + logger.info`reclaimed ${String(result.reclaimedBytes)} bytes from ${dir}: packs ${String(result.before.packCount)} to ${String(result.after.packCount)}, loose ${String(result.before.looseObjectCount)} to ${String(result.after.looseObjectCount)}`; + warnIfOverBudget(dir, result.after.gitBytes, policy.warnBytes); + } catch (err) { + logger.warn`GC of ${dir} failed; the repo is unchanged but unreclaimed — ${err instanceof Error ? err.message : String(err)}`; + warnIfOverBudget(dir, gitBytes(dir), policy.warnBytes); + } +} + +/** + * {@link maybeGCUnderLock} for callers that do not already hold the repo's + * per-directory lock — it acquires the lock for the duration. The hub's + * substrate uses this from inside its own higher-level lock; sidecar writers + * that already hold the per-directory lock call `maybeGCUnderLock` directly. + */ +export async function maybeGC(dir: string, policy: GCPolicy): Promise { + return withRepoDirLock(dir, () => maybeGCUnderLock(dir, policy)); +} diff --git a/vendor/intx-storage-isogit/src/history.ts b/vendor/intx-storage-isogit/src/history.ts new file mode 100644 index 000000000..0530ebdba --- /dev/null +++ b/vendor/intx-storage-isogit/src/history.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import git from "isomorphic-git"; +import type { ContextCommit } from "@intx/types/runtime"; +import { AUTHOR } from "./init"; + +/** + * Switch the working tree to the named branch. The branch must already exist. + */ +export async function switchBranch(dir: string, ref: string): Promise { + await git.checkout({ fs, dir, ref }); +} + +/** + * Create a new branch at HEAD and immediately switch to it. + */ +export async function createAndSwitchBranch( + dir: string, + name: string, +): Promise { + await git.branch({ fs, dir, ref: name }); + await git.checkout({ fs, dir, ref: name }); +} + +/** + * Return the name of the currently checked-out branch. + */ +export async function currentBranch(dir: string): Promise { + const branch = await git.currentBranch({ fs, dir }); + if (branch === null || branch === undefined) { + throw new Error("Repository is in detached HEAD state"); + } + return branch; +} + +/** + * List all local branches. + */ +export async function listBranches(dir: string): Promise { + return git.listBranches({ fs, dir }); +} + +/** + * Return recent commits as ContextCommit entries. + */ +export async function logHistory( + dir: string, + limit = 10, +): Promise { + const entries = await git.log({ fs, dir, depth: limit }); + return entries.map((e) => { + const base = { + hash: e.oid, + message: e.commit.message.trimEnd(), + timestamp: e.commit.author.timestamp * 1000, + }; + const parent = e.commit.parent[0]; + return parent !== undefined ? { ...base, parentHash: parent } : base; + }); +} + +export { AUTHOR }; diff --git a/vendor/intx-storage-isogit/src/index.ts b/vendor/intx-storage-isogit/src/index.ts new file mode 100644 index 000000000..653797f11 --- /dev/null +++ b/vendor/intx-storage-isogit/src/index.ts @@ -0,0 +1,70 @@ +import type { ContextStore, AuditStore } from "@intx/types/runtime"; +import { initAgentRepo } from "./init"; +import { IsogitStore, type DurableMirrorReads } from "./store"; +import type { CommitSigner } from "./signer"; +import type { GCPolicy } from "./gc"; + +export type { ContextStore, AuditStore, CommitSigner }; +export type { + CommitVerifier, + TreeValidator, + TreeValidatorResult, +} from "./pack-receive"; +export { IsogitStore }; +export type { DurableMirrorReads }; +export { + switchBranch, + createAndSwitchBranch, + currentBranch, + listBranches, + logHistory, +} from "./history"; +export { initRepo, initAgentRepo, type InitRepoOpts } from "./init"; +export { applyPack, receivePackObjects } from "./pack-receive"; +export { + createDeployPack, + createNegotiatedPack, + type IncludeShaPredicate, +} from "./pack-send"; +export { collectReachableObjects } from "./object-walk"; +export { + repoDiskUsage, + listRepoRefs, + gitBytes, + countLooseObjects, + countPackFiles, + type RepoDiskUsage, +} from "./repo-disk"; +export { + runGC, + maybeGC, + type RetentionPolicy, + type GCResult, + type GCPolicy, +} from "./gc"; +export { + createMailAuditStore, + listMail, + type MailAuditStore, + type MailCommitOptions, + type MailDirection, + type MailCommitResult, + type MailEntry, +} from "./mail-store"; + +/** + * Initialize an agent repository at `dir` and return a store backed by that + * repository. The returned object implements both ContextStore (inference + * state) and AuditStore (tool authorization records). + * + * When `gcPolicy` is supplied, each commit reclaims the repo on the write + * path once it crosses the policy's thresholds. + */ +export async function createIsogitStore( + dir: string, + signer?: CommitSigner, + gcPolicy?: GCPolicy, +): Promise { + await initAgentRepo(dir); + return new IsogitStore(dir, signer, gcPolicy); +} diff --git a/vendor/intx-storage-isogit/src/init.test.ts b/vendor/intx-storage-isogit/src/init.test.ts new file mode 100644 index 000000000..3d4c8255d --- /dev/null +++ b/vendor/intx-storage-isogit/src/init.test.ts @@ -0,0 +1,203 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import git from "isomorphic-git"; +import { + generateKeyPair, + createSSHSignature, + verifySSHSignature, +} from "@intx/crypto"; +import { initRepo } from "./init"; +import type { CommitSigner } from "./signer"; + +const tempDirs: string[] = []; + +async function tempDir(): Promise { + const d = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "interchange-test-"), + ); + tempDirs.push(d); + return d; +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })), + ); +}); + +describe("initRepo unsigned default", () => { + test("produces an unsigned genesis commit authored as harness", async () => { + const dir = await tempDir(); + await initRepo(dir); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (entry === undefined) throw new Error("no commit in log"); + + expect(entry.commit.author.name).toBe("interchange-harness"); + expect(entry.commit.author.email).toBe("harness@interchange.local"); + expect(entry.commit.gpgsig).toBeUndefined(); + expect(entry.commit.message.trim()).toBe("Initialize repository"); + }); + + test("points HEAD at main", async () => { + const dir = await tempDir(); + await initRepo(dir); + + const head = await fs.promises.readFile( + path.join(dir, ".git", "HEAD"), + "utf-8", + ); + expect(head.trim()).toBe("ref: refs/heads/main"); + + const branch = await git.currentBranch({ fs, dir }); + expect(branch).toBe("main"); + }); + + test("is idempotent on a directory that already contains a repo", async () => { + const dir = await tempDir(); + await initRepo(dir); + const before = await git.log({ fs, dir, depth: 10 }); + + await initRepo(dir); + const after = await git.log({ fs, dir, depth: 10 }); + + expect(after.length).toBe(before.length); + expect(after[0]?.oid).toBe(before[0]?.oid); + }); + + test("writes the default gitignore body when no override is supplied", async () => { + const dir = await tempDir(); + await initRepo(dir); + const body = await fs.promises.readFile( + path.join(dir, ".gitignore"), + "utf-8", + ); + expect(body).toBe("keys/\n"); + }); +}); + +describe("initRepo gitignore override", () => { + test("writes the supplied gitignore body into the genesis tree", async () => { + const dir = await tempDir(); + const customBody = + ".DS_Store\n.idea/\nnode_modules/\nkeys/\ndist/\nbuild/\n"; + await initRepo(dir, { gitignore: customBody }); + + const onDisk = await fs.promises.readFile( + path.join(dir, ".gitignore"), + "utf-8", + ); + expect(onDisk).toBe(customBody); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (entry === undefined) throw new Error("no commit in log"); + const { tree } = await git.readTree({ + fs, + dir, + oid: entry.commit.tree, + }); + const gitignoreEntry = tree.find((e) => e.path === ".gitignore"); + if (gitignoreEntry === undefined) { + throw new Error(".gitignore not staged in genesis tree"); + } + const { blob } = await git.readBlob({ + fs, + dir, + oid: gitignoreEntry.oid, + }); + expect(new TextDecoder().decode(blob)).toBe(customBody); + }); +}); + +describe("initRepo with signing callback", () => { + test("produces a signed genesis commit authored as interchange-hub", async () => { + const keyPair = await generateKeyPair(); + const signer: CommitSigner = async (payload) => + createSSHSignature(payload, keyPair.privateKey, keyPair.publicKey); + + const dir = await tempDir(); + await initRepo(dir, { signer }); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (entry === undefined) throw new Error("no commit in log"); + + expect(entry.commit.author.name).toBe("interchange-hub"); + expect(entry.commit.author.email).toBe("hub@interchange.local"); + expect(entry.commit.gpgsig).toBeDefined(); + }); + + test("the signed genesis verifies against the hub's public key", async () => { + const keyPair = await generateKeyPair(); + const signer: CommitSigner = async (payload) => + createSSHSignature(payload, keyPair.privateKey, keyPair.publicKey); + + const dir = await tempDir(); + await initRepo(dir, { signer }); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (entry === undefined) throw new Error("no commit in log"); + + const signature = entry.commit.gpgsig; + if (signature === undefined) throw new Error("commit was not signed"); + + const { object } = await git.readObject({ + fs, + dir, + oid: entry.oid, + format: "content", + }); + if (!(object instanceof Uint8Array)) { + throw new Error("expected raw commit content as Uint8Array"); + } + const content = new TextDecoder().decode(object); + + const gpgsigIdx = content.indexOf("\ngpgsig "); + let endIdx = gpgsigIdx + 1; + while (endIdx < content.length) { + const nlIdx = content.indexOf("\n", endIdx); + if (nlIdx === -1) break; + endIdx = nlIdx + 1; + if (endIdx < content.length && content[endIdx] !== " ") break; + } + const payload = + content.substring(0, gpgsigIdx) + "\n" + content.substring(endIdx); + + expect( + await verifySSHSignature(payload, signature, keyPair.publicKey), + ).toBe(true); + }); + + test("points HEAD at main when signing", async () => { + const keyPair = await generateKeyPair(); + const signer: CommitSigner = async (payload) => + createSSHSignature(payload, keyPair.privateKey, keyPair.publicKey); + + const dir = await tempDir(); + await initRepo(dir, { signer }); + + const head = await fs.promises.readFile( + path.join(dir, ".git", "HEAD"), + "utf-8", + ); + expect(head.trim()).toBe("ref: refs/heads/main"); + }); + + test("is idempotent on a directory that already contains a signed repo", async () => { + const keyPair = await generateKeyPair(); + const signer: CommitSigner = async (payload) => + createSSHSignature(payload, keyPair.privateKey, keyPair.publicKey); + + const dir = await tempDir(); + await initRepo(dir, { signer }); + const before = await git.log({ fs, dir, depth: 10 }); + + await initRepo(dir, { signer }); + const after = await git.log({ fs, dir, depth: 10 }); + + expect(after.length).toBe(before.length); + expect(after[0]?.oid).toBe(before[0]?.oid); + }); +}); diff --git a/vendor/intx-storage-isogit/src/init.ts b/vendor/intx-storage-isogit/src/init.ts new file mode 100644 index 000000000..04e597b36 --- /dev/null +++ b/vendor/intx-storage-isogit/src/init.ts @@ -0,0 +1,103 @@ +import fs from "node:fs"; +import path from "node:path"; +import git from "isomorphic-git"; +import type { CommitSigner } from "./signer"; +import { buildSigningArgs } from "./commit-helpers"; + +const AUTHOR = { + name: "interchange-harness", + email: "harness@interchange.local", +}; + +const HUB_AUTHOR = { + name: "interchange-hub", + email: "hub@interchange.local", +}; + +const DEFAULT_GITIGNORE = "keys/\n"; + +async function isGitRepo(dir: string): Promise { + return fs.promises + .stat(path.join(dir, ".git")) + .then(() => true) + .catch(() => false); +} + +/** + * Per-call options for `initRepo`. + * + * - `signer`: enables a hub-authored signed genesis (gpgsig header + * populated via the callback). When omitted, the genesis is + * authored as the harness identity and unsigned. + * - `gitignore`: overrides the body written to `.gitignore` in the + * genesis tree. When omitted, the default `keys/\n` body is used, + * keeping the historical behaviour for every existing caller. + * The asset-route REST handler ships a richer body that includes + * OS/editor cruft, common build output, and `keys/`. + */ +export type InitRepoOpts = { + signer?: CommitSigner; + gitignore?: string; +}; + +/** + * Initialize a git repository with a .gitignore and an empty initial commit. + * Idempotent: safe to call on a directory that already contains a git repo. + * + * Used by the hub for repos that don't need sidecar-specific scaffolding. + * isomorphic-git requires at least one commit before branching operations + * work, so the initial commit is always created. + */ +export async function initRepo( + dir: string, + opts: InitRepoOpts = {}, +): Promise { + await fs.promises.mkdir(dir, { recursive: true }); + + if (await isGitRepo(dir)) return; + + await git.init({ fs, dir, defaultBranch: "main" }); + + const gitignoreBody = opts.gitignore ?? DEFAULT_GITIGNORE; + await fs.promises.writeFile(path.join(dir, ".gitignore"), gitignoreBody); + await git.add({ fs, dir, filepath: ".gitignore" }); + + const author = opts.signer === undefined ? AUTHOR : HUB_AUTHOR; + await git.commit({ + fs, + dir, + message: "Initialize repository", + author, + ...buildSigningArgs(opts.signer), + }); +} + +/** + * Initialize a sidecar-side agent repository with the state/ directory + * structure. Creates a single initial commit containing only `.gitignore`; + * subsequent reactor cycles overwrite the per-cycle files (`turns.jsonl`, + * `prompt.jsonl`, `response.jsonl`, `manifest.jsonl`, `metadata.json`) at the + * repository root and commit them via `commit({ message })`. + * + * Idempotent: safe to call on a directory that already contains a git repo. + */ +export async function initAgentRepo(dir: string): Promise { + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.mkdir(path.join(dir, "state"), { recursive: true }); + + if (await isGitRepo(dir)) return; + + await git.init({ fs, dir, defaultBranch: "main" }); + + await fs.promises.writeFile(path.join(dir, ".gitignore"), "keys/\n"); + await git.add({ fs, dir, filepath: ".gitignore" }); + + await git.commit({ + fs, + dir, + message: "Initialize agent repository", + author: AUTHOR, + }); +} + +export { AUTHOR, HUB_AUTHOR }; diff --git a/vendor/intx-storage-isogit/src/isogit-helpers.ts b/vendor/intx-storage-isogit/src/isogit-helpers.ts new file mode 100644 index 000000000..13e206ff2 --- /dev/null +++ b/vendor/intx-storage-isogit/src/isogit-helpers.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import git from "isomorphic-git"; +import { type } from "arktype"; + +const CommitObject = type({ tree: "string" }); +const TreeEntry = type({ oid: "string", type: "string" }); +const RawObject = type({ object: type.instanceOf(Uint8Array) }); + +export async function readCommitObject(dir: string, oid: string) { + const { object } = await git.readObject({ fs, dir, oid, format: "parsed" }); + return CommitObject.assert(object); +} + +export async function readTreeEntries(dir: string, oid: string) { + const { object } = await git.readObject({ fs, dir, oid, format: "parsed" }); + return TreeEntry.array().assert(object); +} + +export async function readRawObject(dir: string, oid: string) { + const { object } = await git.readObject({ fs, dir, oid, format: "content" }); + return RawObject.assert({ object }); +} diff --git a/vendor/intx-storage-isogit/src/mail-store.test.ts b/vendor/intx-storage-isogit/src/mail-store.test.ts new file mode 100644 index 000000000..c8c09924a --- /dev/null +++ b/vendor/intx-storage-isogit/src/mail-store.test.ts @@ -0,0 +1,266 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { initAgentRepo } from "./init"; +import { createMailAuditStore, listMail } from "./mail-store"; + +function buildRawMessage(opts: { + messageId: string; + from?: string; + to?: string; + inReplyTo?: string; + references?: string[]; + body?: string; +}): Uint8Array { + const lines: string[] = []; + lines.push(`Message-ID: ${opts.messageId}`); + lines.push(`From: ${opts.from ?? "sender@example.com"}`); + lines.push(`To: ${opts.to ?? "recipient@example.com"}`); + lines.push(`Date: ${new Date().toUTCString()}`); + if (opts.inReplyTo !== undefined) { + lines.push(`In-Reply-To: ${opts.inReplyTo}`); + } + if (opts.references !== undefined && opts.references.length > 0) { + lines.push(`References: ${opts.references.join(" ")}`); + } + lines.push(""); + lines.push(opts.body ?? "test body"); + return new TextEncoder().encode(lines.join("\r\n")); +} + +let testDir: string; + +beforeEach(async () => { + testDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mail-store-")); + await initAgentRepo(testDir); +}); + +describe("createMailAuditStore", () => { + test("first message creates a new thread", async () => { + const store = await createMailAuditStore(testDir); + const raw = buildRawMessage({ messageId: "" }); + const result = await store.commitMail(raw, "in"); + if (result === null) throw new Error("expected non-null result"); + + expect(result.messageId).toBe(""); + expect(result.threadId).toMatch(/^[0-9a-f]{8}$/); + expect(result.filepath).toBe(`state/mail/${result.threadId}/0001-in.eml`); + + const filePath = path.join(testDir, result.filepath); + const stored = await fs.promises.readFile(filePath, "utf-8"); + expect(stored).toBe(new TextDecoder().decode(raw)); + }); + + test("reply via In-Reply-To joins existing thread", async () => { + const store = await createMailAuditStore(testDir); + + const msg1 = buildRawMessage({ messageId: "" }); + const r1 = await store.commitMail(msg1, "in"); + if (r1 === null) throw new Error("expected non-null r1"); + + const msg2 = buildRawMessage({ + messageId: "", + inReplyTo: "", + }); + const r2 = await store.commitMail(msg2, "out"); + if (r2 === null) throw new Error("expected non-null r2"); + + expect(r2.threadId).toBe(r1.threadId); + expect(r2.filepath).toBe(`state/mail/${r1.threadId}/0002-out.eml`); + }); + + test("reply via References joins correct thread", async () => { + const store = await createMailAuditStore(testDir); + + const msg1 = buildRawMessage({ messageId: "" }); + const r1 = await store.commitMail(msg1, "in"); + if (r1 === null) throw new Error("expected non-null r1"); + + const msg2 = buildRawMessage({ + messageId: "", + references: [""], + }); + const r2 = await store.commitMail(msg2, "out"); + if (r2 === null) throw new Error("expected non-null r2"); + + expect(r2.threadId).toBe(r1.threadId); + }); + + test("unrelated message creates a separate thread", async () => { + const store = await createMailAuditStore(testDir); + + const msg1 = buildRawMessage({ messageId: "" }); + const r1 = await store.commitMail(msg1, "in"); + if (r1 === null) throw new Error("expected non-null r1"); + + const msg2 = buildRawMessage({ messageId: "" }); + const r2 = await store.commitMail(msg2, "in"); + if (r2 === null) throw new Error("expected non-null r2"); + + expect(r2.threadId).not.toBe(r1.threadId); + }); + + test("duplicate Message-ID throws", async () => { + const store = await createMailAuditStore(testDir); + + const msg = buildRawMessage({ messageId: "" }); + await store.commitMail(msg, "in"); + + expect(store.commitMail(msg, "in")).rejects.toThrow( + "Duplicate mail: Message-ID already stored", + ); + }); + + test("duplicate Message-ID returns null with ignoreDuplicate", async () => { + const store = await createMailAuditStore(testDir); + + const msg = buildRawMessage({ messageId: "" }); + await store.commitMail(msg, "in"); + + const result = await store.commitMail(msg, "in", { + ignoreDuplicate: true, + }); + expect(result).toBeNull(); + }); + + test("missing Message-ID throws", async () => { + const store = await createMailAuditStore(testDir); + + const raw = new TextEncoder().encode( + "From: sender@example.com\r\nDate: Mon, 01 Jan 2024 00:00:00 GMT\r\n\r\nbody", + ); + + expect(store.commitMail(raw, "in")).rejects.toThrow( + "Message-ID header is missing or empty", + ); + }); + + test("index rebuilds correctly from disk on init", async () => { + const store1 = await createMailAuditStore(testDir); + + const msg1 = buildRawMessage({ messageId: "" }); + const r1 = await store1.commitMail(msg1, "in"); + if (r1 === null) throw new Error("expected non-null r1"); + + const msg2 = buildRawMessage({ + messageId: "", + inReplyTo: "", + }); + await store1.commitMail(msg2, "out"); + + // Create a fresh store from the same directory + const store2 = await createMailAuditStore(testDir); + + const msg3 = buildRawMessage({ + messageId: "", + inReplyTo: "", + }); + const r3 = await store2.commitMail(msg3, "in"); + if (r3 === null) throw new Error("expected non-null r3"); + + expect(r3.threadId).toBe(r1.threadId); + expect(r3.filepath).toBe(`state/mail/${r1.threadId}/0003-in.eml`); + }); + + test("References list walks to find first matching thread", async () => { + const store = await createMailAuditStore(testDir); + + const msg1 = buildRawMessage({ messageId: "" }); + const r1 = await store.commitMail(msg1, "in"); + if (r1 === null) throw new Error("expected non-null r1"); + + // Reference an unknown ID first, then the known one + const msg2 = buildRawMessage({ + messageId: "", + references: ["", ""], + }); + const r2 = await store.commitMail(msg2, "out"); + if (r2 === null) throw new Error("expected non-null r2"); + + expect(r2.threadId).toBe(r1.threadId); + }); +}); + +describe("listMail", () => { + test("returns empty array when no mail exists", async () => { + const entries = await listMail(testDir); + expect(entries).toEqual([]); + }); + + test("returns single entry after one commit", async () => { + const store = await createMailAuditStore(testDir); + const raw = buildRawMessage({ messageId: "" }); + await store.commitMail(raw, "in"); + + const entries = await listMail(testDir); + expect(entries).toHaveLength(1); + + const entry = entries[0]; + if (entry === undefined) throw new Error("expected entry"); + expect(entry.messageId).toBe(""); + expect(entry.direction).toBe("in"); + expect(entry.ordinal).toBe(1); + expect(entry.raw).toEqual(raw); + }); + + test("returns entries sorted by threadId then ordinal", async () => { + const store = await createMailAuditStore(testDir); + + const msg1 = buildRawMessage({ messageId: "" }); + const r1 = await store.commitMail(msg1, "in"); + if (r1 === null) throw new Error("expected non-null r1"); + + const msg2 = buildRawMessage({ + messageId: "", + inReplyTo: "", + }); + await store.commitMail(msg2, "out"); + + const msg3 = buildRawMessage({ messageId: "" }); + const r3 = await store.commitMail(msg3, "in"); + if (r3 === null) throw new Error("expected non-null r3"); + + const entries = await listMail(testDir); + expect(entries).toHaveLength(3); + + // Entries are sorted by threadId then ordinal — verify cross-thread + // ordering is lexicographic and entries within a thread are contiguous + const threadIds = entries.map((e) => e.threadId); + const uniqueThreadIds = [...new Set(threadIds)]; + const sortedThreadIds = [...uniqueThreadIds].sort(); + expect(uniqueThreadIds).toEqual(sortedThreadIds); + + // Thread 1 entries should be grouped and ordered + const thread1 = entries.filter((e) => e.threadId === r1.threadId); + expect(thread1).toHaveLength(2); + expect(thread1[0]?.ordinal).toBe(1); + expect(thread1[0]?.direction).toBe("in"); + expect(thread1[1]?.ordinal).toBe(2); + expect(thread1[1]?.direction).toBe("out"); + + // Thread 2 + const thread2 = entries.filter((e) => e.threadId === r3.threadId); + expect(thread2).toHaveLength(1); + expect(thread2[0]?.ordinal).toBe(1); + }); + + test("reads correctly from a fresh directory without store", async () => { + // Write some mail via store, then read via standalone listMail + const store = await createMailAuditStore(testDir); + const raw = buildRawMessage({ + messageId: "", + from: "alice@example.com", + to: "bob@example.com", + body: "hello from alice", + }); + await store.commitMail(raw, "in"); + + // listMail should work without a store instance + const entries = await listMail(testDir); + expect(entries).toHaveLength(1); + const entry = entries[0]; + if (entry === undefined) throw new Error("expected entry"); + expect(new TextDecoder().decode(entry.raw)).toContain("hello from alice"); + }); +}); diff --git a/vendor/intx-storage-isogit/src/mail-store.ts b/vendor/intx-storage-isogit/src/mail-store.ts new file mode 100644 index 000000000..7d2c794ee --- /dev/null +++ b/vendor/intx-storage-isogit/src/mail-store.ts @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import path from "node:path"; +import git from "isomorphic-git"; +import { parseHeaderSection } from "@intx/mime"; +import { hexEncode } from "@intx/types"; +import { AUTHOR } from "./init"; +import type { CommitSigner } from "./signer"; +import { buildSigningArgs } from "./commit-helpers"; +import { withRepoDirLock } from "./repo-lock"; + +const MAIL_DIR = "state/mail"; + +export type MailDirection = "in" | "out"; + +export type MailCommitResult = { + threadId: string; + messageId: string; + filepath: string; +}; + +export type MailCommitOptions = { + ignoreDuplicate?: boolean; + checkpointHash?: string; +}; + +export type MailEntry = { + threadId: string; + ordinal: number; + direction: MailDirection; + messageId: string; + raw: Uint8Array; +}; + +export type MailAuditStore = { + commitMail( + rawMessage: Uint8Array, + direction: MailDirection, + options?: MailCommitOptions, + ): Promise; +}; + +type ThreadState = { + nextOrdinal: number; +}; + +function generateThreadId(): string { + return hexEncode(crypto.getRandomValues(new Uint8Array(4))); +} + +function formatOrdinal(n: number): string { + return String(n).padStart(4, "0"); +} + +function parseThreadingHeaders(raw: Uint8Array): { + messageId: string; + inReplyTo: string | undefined; + references: string[]; +} { + const { headers } = parseHeaderSection(raw); + const messageId = headers.get("message-id"); + if (messageId === undefined || messageId.trim() === "") { + throw new Error("Message-ID header is missing or empty"); + } + const inReplyTo = headers.get("in-reply-to"); + const refsRaw = headers.get("references"); + const references = refsRaw ? refsRaw.split(/\s+/).filter(Boolean) : []; + return { messageId, inReplyTo, references }; +} + +export async function createMailAuditStore( + dir: string, + signer?: CommitSigner, +): Promise { + const signingArgs = buildSigningArgs(signer); + + // Message-ID -> thread-id + const messageIndex = new Map(); + // thread-id -> thread state + const threads = new Map(); + + await rebuildIndex(dir, messageIndex, threads); + + function resolveThread( + inReplyTo: string | undefined, + references: string[], + ): string { + if (inReplyTo !== undefined) { + const threadId = messageIndex.get(inReplyTo); + if (threadId !== undefined) return threadId; + } + for (const ref of references) { + const threadId = messageIndex.get(ref); + if (threadId !== undefined) return threadId; + } + return generateThreadId(); + } + + function peekNextOrdinal(threadId: string): number { + const state = threads.get(threadId); + if (state === undefined) return 1; + return state.nextOrdinal; + } + + function advanceOrdinal(threadId: string): void { + let state = threads.get(threadId); + if (state === undefined) { + state = { nextOrdinal: 2 }; + threads.set(threadId, state); + } else { + state.nextOrdinal++; + } + } + + async function commitMail( + rawMessage: Uint8Array, + direction: MailDirection, + options?: MailCommitOptions, + ): Promise { + // The whole body runs under the per-directory lock: the ordinal peek, + // the commit, and the in-memory index/ordinal advance must be atomic + // against a concurrent reactor commit or GC pass sharing this repo. + return withRepoDirLock(dir, async () => { + const { messageId, inReplyTo, references } = + parseThreadingHeaders(rawMessage); + + if (messageIndex.has(messageId)) { + if (options?.ignoreDuplicate === true) return null; + throw new Error( + `Duplicate mail: Message-ID ${messageId} already stored`, + ); + } + + const threadId = resolveThread(inReplyTo, references); + const ordinal = peekNextOrdinal(threadId); + const filename = `${formatOrdinal(ordinal)}-${direction}.eml`; + const filepath = path.join(MAIL_DIR, threadId, filename); + + const fullDir = path.join(dir, MAIL_DIR, threadId); + await fs.promises.mkdir(fullDir, { recursive: true }); + + const fullPath = path.join(dir, filepath); + await fs.promises.writeFile(fullPath, rawMessage); + await git.add({ fs, dir, filepath }); + + const label = direction === "in" ? "inbound" : "outbound"; + const subject = `Record ${label} mail ${messageId}`; + const message = + options?.checkpointHash !== undefined + ? `${subject}\n\nCheckpoint: ${options.checkpointHash}` + : subject; + await git.commit({ + fs, + dir, + message, + author: AUTHOR, + ...signingArgs, + }); + + advanceOrdinal(threadId); + messageIndex.set(messageId, threadId); + return { threadId, messageId, filepath }; + }); + } + + return { commitMail }; +} + +function parseFilename(filename: string): { + ordinal: number; + direction: MailDirection; +} { + const stem = filename.replace(/\.eml$/, ""); + const dashIndex = stem.indexOf("-"); + if (dashIndex === -1) { + throw new Error(`Malformed mail filename: ${filename}`); + } + + const ordinalStr = stem.slice(0, dashIndex); + const ordinal = parseInt(ordinalStr, 10); + if (isNaN(ordinal)) { + throw new Error(`Malformed mail filename: ${filename}`); + } + + const directionStr = stem.slice(dashIndex + 1); + if (directionStr !== "in" && directionStr !== "out") { + throw new Error( + `Invalid mail direction '${directionStr}' in filename: ${filename}`, + ); + } + + return { ordinal, direction: directionStr }; +} + +async function scanMail(dir: string): Promise { + const mailDir = path.join(dir, MAIL_DIR); + + let threadDirs: string[]; + try { + threadDirs = await fs.promises.readdir(mailDir); + } catch (e: unknown) { + if (e instanceof Error && "code" in e && e.code === "ENOENT") { + return []; + } + throw e; + } + + threadDirs.sort(); + + const entries: MailEntry[] = []; + + for (const threadId of threadDirs) { + const threadPath = path.join(mailDir, threadId); + const stat = await fs.promises.stat(threadPath); + if (!stat.isDirectory()) continue; + + const files = await fs.promises.readdir(threadPath); + const emlFiles = files.filter((f) => f.endsWith(".eml")).sort(); + + for (const file of emlFiles) { + const { ordinal, direction } = parseFilename(file); + const fullPath = path.join(threadPath, file); + const raw = await fs.promises.readFile(fullPath); + const { messageId } = parseThreadingHeaders(raw); + + entries.push({ threadId, ordinal, direction, messageId, raw }); + } + } + + return entries; +} + +export async function listMail(dir: string): Promise { + return scanMail(dir); +} + +async function rebuildIndex( + dir: string, + messageIndex: Map, + threads: Map, +): Promise { + const entries = await scanMail(dir); + + for (const entry of entries) { + messageIndex.set(entry.messageId, entry.threadId); + + const state = threads.get(entry.threadId); + if (state === undefined) { + threads.set(entry.threadId, { nextOrdinal: entry.ordinal + 1 }); + } else if (entry.ordinal >= state.nextOrdinal) { + state.nextOrdinal = entry.ordinal + 1; + } + } +} diff --git a/vendor/intx-storage-isogit/src/object-walk.ts b/vendor/intx-storage-isogit/src/object-walk.ts new file mode 100644 index 000000000..d6071ee36 --- /dev/null +++ b/vendor/intx-storage-isogit/src/object-walk.ts @@ -0,0 +1,30 @@ +import { readCommitObject, readTreeEntries } from "./isogit-helpers"; + +/** + * Collect all unique object OIDs reachable from a commit: the commit itself, + * its tree, and all blobs and subtrees recursively. + */ +export async function collectReachableObjects( + dir: string, + commitOid: string, +): Promise { + const seen = new Set(); + seen.add(commitOid); + + const commit = await readCommitObject(dir, commitOid); + seen.add(commit.tree); + + async function walkTree(treeOid: string): Promise { + const entries = await readTreeEntries(dir, treeOid); + for (const entry of entries) { + if (seen.has(entry.oid)) continue; + seen.add(entry.oid); + if (entry.type === "tree") { + await walkTree(entry.oid); + } + } + } + + await walkTree(commit.tree); + return [...seen]; +} diff --git a/vendor/intx-storage-isogit/src/pack-receive.test.ts b/vendor/intx-storage-isogit/src/pack-receive.test.ts new file mode 100644 index 000000000..0e2ad2e38 --- /dev/null +++ b/vendor/intx-storage-isogit/src/pack-receive.test.ts @@ -0,0 +1,1083 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import git from "isomorphic-git"; +import { + generateKeyPair, + createSSHSignature, + verifySSHSignature, +} from "@intx/crypto"; +import { initAgentRepo } from "./init"; +import { + applyPack, + receivePackObjects, + type CommitVerifier, + type TreeValidator, +} from "./pack-receive"; +import { collectReachableObjects } from "./object-walk"; + +const tempDirs: string[] = []; + +async function tempDir(): Promise { + const d = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "interchange-test-"), + ); + tempDirs.push(d); + return d; +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })), + ); +}); + +async function createPackFromRepo( + sourceDir: string, + oids: string[], +): Promise { + const result = await git.packObjects({ + fs, + dir: sourceDir, + oids, + write: false, + }); + if (result.packfile === undefined) { + throw new Error("packObjects returned no packfile"); + } + return result.packfile; +} + +async function makeSourceRepo(): Promise<{ + dir: string; + commitSha: string; + oids: string[]; +}> { + const dir = await tempDir(); + await git.init({ fs, dir, defaultBranch: "main" }); + + const filePath = path.join(dir, "deploy", "prompt.txt"); + await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); + await fs.promises.writeFile(filePath, "You are a helpful agent."); + await git.add({ fs, dir, filepath: "deploy/prompt.txt" }); + + const commitSha = await git.commit({ + fs, + dir, + message: "Initial deploy", + author: { name: "Test", email: "test@test.dev" }, + }); + + const walkResult = await git.log({ fs, dir, depth: 1 }); + const entry = walkResult[0]; + if (entry === undefined) throw new Error("no commit"); + + const oids = await collectReachableObjects(dir, commitSha); + return { dir, commitSha, oids }; +} + +describe("applyPack", () => { + test("applies a packfile and updates the ref", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack( + targetDir, + pack, + "refs/heads/deploy", + source.commitSha, + "test-transfer-1", + ); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/deploy", + }); + expect(resolved).toBe(source.commitSha); + }); + + test("retains pack and index in objects/pack after success", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack( + targetDir, + pack, + "refs/heads/deploy", + source.commitSha, + "cleanup-test", + ); + + const packPath = path.join( + targetDir, + ".git", + "objects", + "pack", + "pack-recv-cleanup-test.pack", + ); + const idxPath = path.join( + targetDir, + ".git", + "objects", + "pack", + "pack-recv-cleanup-test.idx", + ); + + // Pack and index are retained so git can read objects from them. + await fs.promises.access(packPath); + await fs.promises.access(idxPath); + }); + + test("checks out files to the working tree", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack( + targetDir, + pack, + "refs/heads/deploy", + source.commitSha, + "checkout-test", + ); + + const content = await fs.promises.readFile( + path.join(targetDir, "deploy", "prompt.txt"), + "utf-8", + ); + expect(content).toBe("You are a helpful agent."); + }); + + test("preserves executable mode on checked-out files", async () => { + const sourceDir = await tempDir(); + await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); + + const scriptPath = path.join(sourceDir, "deploy", "run.sh"); + await fs.promises.mkdir(path.join(sourceDir, "deploy"), { + recursive: true, + }); + await fs.promises.writeFile(scriptPath, "#!/bin/sh\necho hello\n", { + mode: 0o755, + }); + await git.add({ fs, dir: sourceDir, filepath: "deploy/run.sh" }); + + const commitSha = await git.commit({ + fs, + dir: sourceDir, + message: "Add executable script", + author: { name: "Test", email: "test@test.dev" }, + }); + + const oids = await collectReachableObjects(sourceDir, commitSha); + const pack = await createPackFromRepo(sourceDir, oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack( + targetDir, + pack, + "refs/heads/deploy", + commitSha, + "exec-test", + ); + + const stat = await fs.promises.stat( + path.join(targetDir, "deploy", "run.sh"), + ); + const isExecutable = (stat.mode & 0o111) !== 0; + expect(isExecutable).toBe(true); + }); + + test("removes stale files when a second deploy drops content", async () => { + // First commit: two state subtrees (turns + responses). + const sourceDir = await tempDir(); + await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); + + const turnsDir = path.join(sourceDir, "state", "turns"); + const responsesDir = path.join(sourceDir, "state", "responses"); + await fs.promises.mkdir(turnsDir, { recursive: true }); + await fs.promises.mkdir(responsesDir, { recursive: true }); + await fs.promises.writeFile( + path.join(turnsDir, "turn-1.json"), + '{"id":"turn-1"}', + ); + await fs.promises.writeFile( + path.join(responsesDir, "response-1.json"), + '{"id":"response-1"}', + ); + await git.add({ + fs, + dir: sourceDir, + filepath: "state/turns/turn-1.json", + }); + await git.add({ + fs, + dir: sourceDir, + filepath: "state/responses/response-1.json", + }); + const sha1 = await git.commit({ + fs, + dir: sourceDir, + message: "Two state subtrees", + author: { name: "Test", email: "test@test.dev" }, + }); + + const oids1 = await collectReachableObjects(sourceDir, sha1); + const pack1 = await createPackFromRepo(sourceDir, oids1); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack(targetDir, pack1, "refs/heads/deploy", sha1, "deploy-v1"); + + // Verify both subtrees exist after first deploy. + await fs.promises.access( + path.join(targetDir, "state", "responses", "response-1.json"), + ); + + // Second commit: remove responses, keep turns. + await fs.promises.rm(responsesDir, { recursive: true }); + await git.remove({ + fs, + dir: sourceDir, + filepath: "state/responses/response-1.json", + }); + const sha2 = await git.commit({ + fs, + dir: sourceDir, + message: "Remove responses", + author: { name: "Test", email: "test@test.dev" }, + }); + + const oids2 = await collectReachableObjects(sourceDir, sha2); + const pack2 = await createPackFromRepo(sourceDir, oids2); + + await applyPack(targetDir, pack2, "refs/heads/deploy", sha2, "deploy-v2"); + + // Turns should still exist. + const turnContent = await fs.promises.readFile( + path.join(targetDir, "state", "turns", "turn-1.json"), + "utf-8", + ); + expect(turnContent).toBe('{"id":"turn-1"}'); + + // Responses should be gone — stale files must not linger. + const responsesExist = await fs.promises + .access(path.join(targetDir, "state", "responses", "response-1.json")) + .then(() => true) + .catch(() => false); + expect(responsesExist).toBe(false); + }); + + test("removes stale top-level directories absent from new tree", async () => { + // First commit: deploy/ and config/ at the top level. + const sourceDir = await tempDir(); + await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); + + await fs.promises.mkdir(path.join(sourceDir, "deploy"), { + recursive: true, + }); + await fs.promises.mkdir(path.join(sourceDir, "config"), { + recursive: true, + }); + await fs.promises.writeFile( + path.join(sourceDir, "deploy", "prompt.md"), + "hello", + ); + await fs.promises.writeFile( + path.join(sourceDir, "config", "settings.json"), + "{}", + ); + await git.add({ fs, dir: sourceDir, filepath: "deploy/prompt.md" }); + await git.add({ fs, dir: sourceDir, filepath: "config/settings.json" }); + const sha1 = await git.commit({ + fs, + dir: sourceDir, + message: "Deploy and config", + author: { name: "Test", email: "test@test.dev" }, + }); + + const oids1 = await collectReachableObjects(sourceDir, sha1); + const pack1 = await createPackFromRepo(sourceDir, oids1); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack(targetDir, pack1, "refs/heads/deploy", sha1, "tld-v1"); + + // Verify both top-level dirs exist. + await fs.promises.access(path.join(targetDir, "config", "settings.json")); + + // Second commit: remove config/ entirely. + await fs.promises.rm(path.join(sourceDir, "config"), { recursive: true }); + await git.remove({ + fs, + dir: sourceDir, + filepath: "config/settings.json", + }); + const sha2 = await git.commit({ + fs, + dir: sourceDir, + message: "Remove config", + author: { name: "Test", email: "test@test.dev" }, + }); + + const oids2 = await collectReachableObjects(sourceDir, sha2); + const pack2 = await createPackFromRepo(sourceDir, oids2); + + await applyPack(targetDir, pack2, "refs/heads/deploy", sha2, "tld-v2"); + + // deploy/ should still exist. + const promptContent = await fs.promises.readFile( + path.join(targetDir, "deploy", "prompt.md"), + "utf-8", + ); + expect(promptContent).toBe("hello"); + + // config/ should be gone. + const configExists = await fs.promises + .access(path.join(targetDir, "config")) + .then(() => true) + .catch(() => false); + expect(configExists).toBe(false); + }); + + test("throws on sha mismatch", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await expect( + applyPack( + targetDir, + pack, + "refs/heads/deploy", + "0000000000000000000000000000000000000000", + "mismatch-test", + ), + ).rejects.toThrow("sha_mismatch"); + }); + + test("cleans up pack files after failure", async () => { + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + // Write garbage as a pack — indexPack will fail + const garbagePack = new Uint8Array([1, 2, 3, 4]); + + await expect( + applyPack( + targetDir, + garbagePack, + "refs/heads/deploy", + "abc123", + "fail-cleanup", + ), + ).rejects.toThrow(); + + const packPath = path.join( + targetDir, + ".git", + "objects", + "pack", + "pack-recv-fail-cleanup.pack", + ); + const idxPath = path.join( + targetDir, + ".git", + "objects", + "pack", + "pack-recv-fail-cleanup.idx", + ); + await expect(fs.promises.access(packPath)).rejects.toThrow(); + await expect(fs.promises.access(idxPath)).rejects.toThrow(); + }); +}); + +async function makeSignedSourceRepo(keyPair: { + privateKey: Uint8Array; + publicKey: Uint8Array; +}): Promise<{ + dir: string; + commitSha: string; + oids: string[]; +}> { + const dir = await tempDir(); + await git.init({ fs, dir, defaultBranch: "main" }); + + const filePath = path.join(dir, "deploy", "prompt.txt"); + await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); + await fs.promises.writeFile(filePath, "You are a helpful agent."); + await git.add({ fs, dir, filepath: "deploy/prompt.txt" }); + + const commitSha = await git.commit({ + fs, + dir, + message: "Signed deploy", + author: { name: "Test", email: "test@test.dev" }, + signingKey: "sshsig", + onSign: async ({ payload }) => ({ + signature: await createSSHSignature( + payload, + keyPair.privateKey, + keyPair.publicKey, + ), + }), + }); + + const oids = await collectReachableObjects(dir, commitSha); + return { dir, commitSha, oids }; +} + +describe("applyPack signature verification", () => { + test("accepts a correctly signed pack", async () => { + const keyPair = await generateKeyPair(); + const source = await makeSignedSourceRepo(keyPair); + const pack = await createPackFromRepo(source.dir, source.oids); + + const verifier: CommitVerifier = (payload, signature) => + verifySSHSignature(payload, signature, keyPair.publicKey); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack( + targetDir, + pack, + "refs/heads/deploy", + source.commitSha, + "signed-ok", + verifier, + ); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/deploy", + }); + expect(resolved).toBe(source.commitSha); + }); + + test("rejects a pack signed with the wrong key", async () => { + const signerKey = await generateKeyPair(); + const verifierKey = await generateKeyPair(); + const source = await makeSignedSourceRepo(signerKey); + const pack = await createPackFromRepo(source.dir, source.oids); + + const verifier: CommitVerifier = (payload, signature) => + verifySSHSignature(payload, signature, verifierKey.publicKey); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await expect( + applyPack( + targetDir, + pack, + "refs/heads/deploy", + source.commitSha, + "wrong-key", + verifier, + ), + ).rejects.toThrow("signature_invalid"); + }); + + test("rejects an unsigned commit when verifier is provided", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const keyPair = await generateKeyPair(); + const verifier: CommitVerifier = (payload, signature) => + verifySSHSignature(payload, signature, keyPair.publicKey); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await expect( + applyPack( + targetDir, + pack, + "refs/heads/deploy", + source.commitSha, + "unsigned", + verifier, + ), + ).rejects.toThrow("signature_unsigned"); + }); + + test("skips verification when no verifier is provided", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack( + targetDir, + pack, + "refs/heads/deploy", + source.commitSha, + "no-verify", + ); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/deploy", + }); + expect(resolved).toBe(source.commitSha); + }); +}); + +async function makeRepoWithPaths( + paths: { filepath: string; content: string }[], +): Promise<{ + dir: string; + commitSha: string; + oids: string[]; +}> { + const dir = await tempDir(); + await git.init({ fs, dir, defaultBranch: "main" }); + + for (const { filepath, content } of paths) { + const fullPath = path.join(dir, filepath); + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.promises.writeFile(fullPath, content); + await git.add({ fs, dir, filepath }); + } + + const commitSha = await git.commit({ + fs, + dir, + message: "Test tree", + author: { name: "Test", email: "test@test.dev" }, + }); + + const oids = await collectReachableObjects(dir, commitSha); + return { dir, commitSha, oids }; +} + +describe("receivePackObjects tree validation", () => { + test("accepts a state-only tree when validator requires state", async () => { + const source = await makeRepoWithPaths([ + { filepath: "state/turns.jsonl", content: "" }, + ]); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const validator: TreeValidator = (paths) => + paths.every((p) => p === "state"); + + await receivePackObjects( + targetDir, + pack, + "refs/heads/state", + source.commitSha, + "state-ok", + null, + validator, + ); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/state", + }); + expect(resolved).toBe(source.commitSha); + }); + + test("rejects a tree with deploy/ when validator requires state only", async () => { + const source = await makeRepoWithPaths([ + { filepath: "state/turns.jsonl", content: "" }, + { filepath: "deploy/prompt.md", content: "evil" }, + ]); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const validator: TreeValidator = (paths) => + paths.every((p) => p === "state"); + + await expect( + receivePackObjects( + targetDir, + pack, + "refs/heads/state", + source.commitSha, + "state-bad", + null, + validator, + ), + ).rejects.toThrow("path_violation"); + }); + + test("surfaces top-level files to the validator alongside directories", async () => { + // A push that injects a top-level file outside the kind handler's + // allowlist (e.g. an `evil.exe` next to legitimate asset subtrees) + // must be visible to the validator. The receivePack path used to + // filter to top-level directories only, which hid such files and + // silently admitted them past the allowlist. + const source = await makeRepoWithPaths([ + { filepath: "tarballs/foo.tgz", content: "" }, + { filepath: "package-registry.json", content: "{}" }, + { filepath: "evil.exe", content: "bad" }, + ]); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const seenPaths: string[] = []; + const validator: TreeValidator = (paths) => { + seenPaths.push(...paths); + return paths.every( + (p) => + p === "tarballs" || + p === ".gitignore" || + p === "package-registry.json", + ); + }; + + await expect( + receivePackObjects( + targetDir, + pack, + "refs/heads/registry", + source.commitSha, + "extra-file", + null, + validator, + ), + ).rejects.toThrow("path_violation"); + expect(seenPaths).toContain("evil.exe"); + expect(seenPaths).toContain("tarballs"); + expect(seenPaths).toContain("package-registry.json"); + }); + + test("accepts any tree when no validator is provided", async () => { + const source = await makeRepoWithPaths([ + { filepath: "state/turns.jsonl", content: "" }, + { filepath: "deploy/prompt.md", content: "anything" }, + ]); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await receivePackObjects( + targetDir, + pack, + "refs/heads/mixed", + source.commitSha, + "no-validate", + null, + ); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/mixed", + }); + expect(resolved).toBe(source.commitSha); + }); +}); + +describe("receivePackObjects CAS", () => { + test("returns null when the ref did not previously exist", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const oldSha = await receivePackObjects( + targetDir, + pack, + "refs/heads/state", + source.commitSha, + "cas-fresh", + null, + ); + + expect(oldSha).toBeNull(); + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/state", + }); + expect(resolved).toBe(source.commitSha); + }); + + test("returns the previous sha after a second update", async () => { + const sourceA = await makeRepoWithPaths([ + { filepath: "state/v1.jsonl", content: "v1" }, + ]); + const packA = await createPackFromRepo(sourceA.dir, sourceA.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const firstOld = await receivePackObjects( + targetDir, + packA, + "refs/heads/state", + sourceA.commitSha, + "cas-first", + null, + ); + expect(firstOld).toBeNull(); + + const sourceB = await makeRepoWithPaths([ + { filepath: "state/v2.jsonl", content: "v2" }, + ]); + const packB = await createPackFromRepo(sourceB.dir, sourceB.oids); + + const secondOld = await receivePackObjects( + targetDir, + packB, + "refs/heads/state", + sourceB.commitSha, + "cas-second", + sourceA.commitSha, + ); + + expect(secondOld).toBe(sourceA.commitSha); + }); + + test("happy path: matching expectedOldSha advances the ref", async () => { + const sourceA = await makeRepoWithPaths([ + { filepath: "state/v1.jsonl", content: "v1" }, + ]); + const packA = await createPackFromRepo(sourceA.dir, sourceA.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await receivePackObjects( + targetDir, + packA, + "refs/heads/state", + sourceA.commitSha, + "cas-happy-first", + null, + ); + + const sourceB = await makeRepoWithPaths([ + { filepath: "state/v2.jsonl", content: "v2" }, + ]); + const packB = await createPackFromRepo(sourceB.dir, sourceB.oids); + + const oldSha = await receivePackObjects( + targetDir, + packB, + "refs/heads/state", + sourceB.commitSha, + "cas-happy-second", + sourceA.commitSha, + ); + + expect(oldSha).toBe(sourceA.commitSha); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/state", + }); + expect(resolved).toBe(sourceB.commitSha); + }); + + test("stale expectedOldSha throws non_fast_forward and leaves ref untouched", async () => { + const sourceA = await makeRepoWithPaths([ + { filepath: "state/v1.jsonl", content: "v1" }, + ]); + const packA = await createPackFromRepo(sourceA.dir, sourceA.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await receivePackObjects( + targetDir, + packA, + "refs/heads/state", + sourceA.commitSha, + "cas-stale-first", + null, + ); + + const sourceB = await makeRepoWithPaths([ + { filepath: "state/v2.jsonl", content: "v2" }, + ]); + const packB = await createPackFromRepo(sourceB.dir, sourceB.oids); + + const bogus = "0".repeat(40); + await expect( + receivePackObjects( + targetDir, + packB, + "refs/heads/state", + sourceB.commitSha, + "cas-stale-second", + bogus, + ), + ).rejects.toThrow("non_fast_forward"); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/state", + }); + expect(resolved).toBe(sourceA.commitSha); + }); + + test("expectedOldSha null asserts ref absence and accepts a first write", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const oldSha = await receivePackObjects( + targetDir, + pack, + "refs/heads/state", + source.commitSha, + "cas-null-ok", + null, + ); + + expect(oldSha).toBeNull(); + }); + + test("expectedOldSha null rejects when the ref already exists", async () => { + const sourceA = await makeRepoWithPaths([ + { filepath: "state/v1.jsonl", content: "v1" }, + ]); + const packA = await createPackFromRepo(sourceA.dir, sourceA.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await receivePackObjects( + targetDir, + packA, + "refs/heads/state", + sourceA.commitSha, + "cas-null-first", + null, + ); + + const sourceB = await makeRepoWithPaths([ + { filepath: "state/v2.jsonl", content: "v2" }, + ]); + const packB = await createPackFromRepo(sourceB.dir, sourceB.oids); + + await expect( + receivePackObjects( + targetDir, + packB, + "refs/heads/state", + sourceB.commitSha, + "cas-null-stale", + null, + ), + ).rejects.toThrow("non_fast_forward"); + }); +}); + +// Helpers used by the unpublish-on-rejection tests below. The pack +// filename layout is derived from `transferId` per +// `publishedPackPaths` in pack-receive.ts; replicate the derivation +// here so the assertions stay decoupled from that helper's accessors +// (no internals exported). +function publishedPackFile(targetDir: string, transferId: string): string { + return path.join( + targetDir, + ".git", + "objects", + "pack", + `pack-recv-${transferId}.pack`, + ); +} + +function publishedIdxFile(targetDir: string, transferId: string): string { + return path.join( + targetDir, + ".git", + "objects", + "pack", + `pack-recv-${transferId}.idx`, + ); +} + +async function fileExists(p: string): Promise { + try { + await fs.promises.access(p); + return true; + } catch { + return false; + } +} + +// These tests assert that post-publish validation rejections in +// receivePackObjects and applyPack remove the published .pack + .idx +// pair from objects/pack/. Without this, rejected packs would +// accumulate as orphan files in objects/pack/ — iso-git does not run +// periodic GC, and the hub does not invoke git gc on agent repos. The +// CAS/sha/path_violation tests above verify the throw and ref state +// but do not verify file cleanup; this section closes that gap. +describe("receivePackObjects unpublishes on post-publish rejection", () => { + test("non_fast_forward removes .pack and .idx; the accepted earlier pack stays", async () => { + const sourceA = await makeRepoWithPaths([ + { filepath: "state/v1.jsonl", content: "v1" }, + ]); + const packA = await createPackFromRepo(sourceA.dir, sourceA.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await receivePackObjects( + targetDir, + packA, + "refs/heads/state", + sourceA.commitSha, + "first-ok", + null, + ); + + const sourceB = await makeRepoWithPaths([ + { filepath: "state/v2.jsonl", content: "v2" }, + ]); + const packB = await createPackFromRepo(sourceB.dir, sourceB.oids); + + const bogus = "0".repeat(40); + await expect( + receivePackObjects( + targetDir, + packB, + "refs/heads/state", + sourceB.commitSha, + "rejected-nff", + bogus, + ), + ).rejects.toThrow("non_fast_forward"); + + expect(await fileExists(publishedPackFile(targetDir, "rejected-nff"))).toBe( + false, + ); + expect(await fileExists(publishedIdxFile(targetDir, "rejected-nff"))).toBe( + false, + ); + expect(await fileExists(publishedPackFile(targetDir, "first-ok"))).toBe( + true, + ); + expect(await fileExists(publishedIdxFile(targetDir, "first-ok"))).toBe( + true, + ); + }); + + test("sha_mismatch removes .pack and .idx", async () => { + const source = await makeRepoWithPaths([ + { filepath: "state/v1.jsonl", content: "v1" }, + ]); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const bogusExpected = "0".repeat(40); + await expect( + receivePackObjects( + targetDir, + pack, + "refs/heads/state", + bogusExpected, + "rejected-sha", + null, + ), + ).rejects.toThrow("sha_mismatch"); + + expect(await fileExists(publishedPackFile(targetDir, "rejected-sha"))).toBe( + false, + ); + expect(await fileExists(publishedIdxFile(targetDir, "rejected-sha"))).toBe( + false, + ); + }); + + test("path_violation removes .pack and .idx", async () => { + const source = await makeRepoWithPaths([ + { filepath: "deploy/prompt.md", content: "evil" }, + ]); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const stateOnly: TreeValidator = (paths) => + paths.every((p) => p === "state"); + + await expect( + receivePackObjects( + targetDir, + pack, + "refs/heads/state", + source.commitSha, + "rejected-pv", + null, + stateOnly, + ), + ).rejects.toThrow("path_violation"); + + expect(await fileExists(publishedPackFile(targetDir, "rejected-pv"))).toBe( + false, + ); + expect(await fileExists(publishedIdxFile(targetDir, "rejected-pv"))).toBe( + false, + ); + }); +}); + +describe("applyPack unpublishes on post-publish rejection", () => { + test("sha_mismatch removes .pack and .idx", async () => { + const source = await makeSourceRepo(); + const pack = await createPackFromRepo(source.dir, source.oids); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + const bogusExpected = "0".repeat(40); + await expect( + applyPack( + targetDir, + pack, + "refs/heads/deploy", + bogusExpected, + "apply-rejected-sha", + ), + ).rejects.toThrow("sha_mismatch"); + + expect( + await fileExists(publishedPackFile(targetDir, "apply-rejected-sha")), + ).toBe(false); + expect( + await fileExists(publishedIdxFile(targetDir, "apply-rejected-sha")), + ).toBe(false); + }); +}); diff --git a/vendor/intx-storage-isogit/src/pack-receive.ts b/vendor/intx-storage-isogit/src/pack-receive.ts new file mode 100644 index 000000000..a0a14158a --- /dev/null +++ b/vendor/intx-storage-isogit/src/pack-receive.ts @@ -0,0 +1,678 @@ +import fs from "node:fs"; +import path from "node:path"; +import git from "isomorphic-git"; +import { readRawObject } from "./isogit-helpers"; +import { withRepoDirLock } from "./repo-lock"; + +/** + * Verifies the signature embedded in a git commit object. + * + * Callers bind this to their verification implementation (e.g. + * verifySSHSignature with the hub's public key). The storage layer does + * not own key material. + * + * Returns true when the signature is valid. Should throw on malformed + * input and return false on cryptographic failure. + */ +export type CommitVerifier = ( + payload: string, + signature: string, +) => Promise; + +export type TreeValidatorResult = true | { ok: false; reason: string }; + +/** + * Validates the contents of a commit's tree. + * + * Callers bind this to their policy (e.g. state packs must only contain + * entries named "state", asset packs must have a SKILL.md with valid + * frontmatter). Return `true` when the tree is acceptable; return + * `{ ok: false, reason }` to reject with a caller-supplied reason that + * the substrate splices into its thrown `path_violation:` message. + * + * Returning `false` is also accepted for back-compatibility and is + * treated as an opaque rejection without a reason. + * + * `topLevelPaths` lists the names directly under the tree root. + * `readBlob` reads any blob in the tree by its repo-root-relative POSIX + * path. `listDir` enumerates the names directly under a + * tree-root-relative POSIX directory path (no trailing slash, no + * leading slash); pass the empty string to list the root. Validators + * that only need path-level checks can ignore `readBlob` and `listDir`. + */ +export type TreeValidator = ( + topLevelPaths: string[], + readBlob: (path: string) => Promise, + listDir: (path: string) => Promise, +) => boolean | TreeValidatorResult | Promise; + +/** + * Strip the gpgsig header from a raw git commit object, producing the + * payload that was originally signed. + * + * isomorphic-git's readCommit().payload uses withoutSignature() which is + * hardcoded to look for PGP armor markers. SSH signatures use different + * markers, so the built-in reconstruction is wrong. This function works + * with any signature format by parsing the header structure directly. + */ +function stripGpgsig(raw: string): string { + const gpgsigIdx = raw.indexOf("\ngpgsig "); + if (gpgsigIdx === -1) return raw; + + // The gpgsig header spans from "\ngpgsig " to the next header line that + // does not start with a space. Continuation lines in git headers are + // indented with a single leading space. + let endIdx = gpgsigIdx + 1; + while (endIdx < raw.length) { + const nlIdx = raw.indexOf("\n", endIdx); + if (nlIdx === -1) break; + endIdx = nlIdx + 1; + if (endIdx < raw.length && raw[endIdx] !== " ") break; + } + + return raw.substring(0, gpgsigIdx) + "\n" + raw.substring(endIdx); +} + +const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/; + +// Sibling of objects/pack/. Lives on the same filesystem so fs.link and +// fs.rename between staging and pack/ are atomic metadata operations. +const STAGING_DIR_NAME = "pack-staging"; + +/** + * Derive the externally-visible final paths a publishPackAtomically call + * will (or has already) written for a given `transferId`. Exported as a + * helper so callers that reject a published pack can locate the files + * to unpublish without re-deriving the naming convention. + */ +function publishedPackPaths( + dir: string, + transferId: string, +): { finalPackPath: string; finalIdxPath: string } { + const packDir = path.join(dir, ".git", "objects", "pack"); + const finalPackPath = path.join(packDir, `pack-recv-${transferId}.pack`); + const finalIdxPath = finalPackPath.replace(/\.pack$/, ".idx"); + return { finalPackPath, finalIdxPath }; +} + +/** + * Remove a previously-published `.pack` + `.idx` pair from objects/pack/. + * + * Callers reject a published pack by calling this after their post-publish + * validation fails (signature, tree-validator, sha mismatch, CAS + * non-fast-forward, etc.). POSIX semantics mean unlink does not affect + * file descriptors a concurrent reader has already opened, so a reader + * mid-call against the rejected pack finishes its read against the + * already-loaded bytes; subsequent calls will not see the pack at all. + * Iso-git does not hold open file descriptors between calls (verified + * by reading `readObjectPacked` at `index.cjs:3394-3398`), so the + * unlink is safe against the standard pack-discovery path. + * + * Wrapped in `.catch(() => undefined)` per rm so a secondary failure + * (permissions, I/O) does not mask whatever rejection the caller is + * about to throw. A failed unlink leaks the rejected pack on disk + * until external cleanup; that is acceptable because the caller is + * already in an error path and the alternative is hiding the real + * cause behind a cleanup throw. + */ +async function unpublishPack(dir: string, transferId: string): Promise { + const { finalPackPath, finalIdxPath } = publishedPackPaths(dir, transferId); + await fs.promises.rm(finalPackPath, { force: true }).catch(() => undefined); + await fs.promises.rm(finalIdxPath, { force: true }).catch(() => undefined); +} + +type TreeEntry = { + type: "blob" | "tree" | "commit"; + mode: string; + path: string; + oid: string; +}; + +async function topLevelNames(dir: string, oid: string): Promise> { + const { tree } = await git.readTree({ fs, dir, oid }); + return new Set(tree.map((e) => e.path)); +} + +/** + * Remove stale working-tree content and write the commit's tree to disk. + * + * Derives the set of deploy-managed top-level entries from the union of the + * old and new commit trees, removes those entries, then writes the new tree. + * Paths that never appear in any commit tree (e.g. .git, state, keys) are + * never touched. + * + * NOTE: The rm-then-write sequence is not atomic. If writeTree fails after rm + * succeeds (e.g. disk full), the working tree will be missing the cleared + * paths. The ref is not updated in that case (caller writes ref after this + * function returns), so a restart will re-read from the prior commit, but the + * working tree will be stale until the next successful applyPack. + */ +async function checkoutTree( + dir: string, + commitSha: string, + ref: string, +): Promise { + const { commit } = await git.readCommit({ fs, dir, oid: commitSha }); + const { tree } = await git.readTree({ fs, dir, oid: commit.tree }); + + // Collect top-level names managed by deploy trees (new + previous). + const managed = new Set(tree.map((e) => e.path)); + + const prevSha = await git.resolveRef({ fs, dir, ref }).catch(() => null); + if (prevSha !== null) { + const { commit: prev } = await git.readCommit({ fs, dir, oid: prevSha }); + for (const name of await topLevelNames(dir, prev.tree)) { + managed.add(name); + } + } + + const existing = await fs.promises.readdir(dir); + for (const name of existing) { + if (!managed.has(name)) continue; + await fs.promises.rm(path.join(dir, name), { + recursive: true, + force: true, + }); + } + + await writeTreeEntries(dir, dir, tree); +} + +async function writeTreeEntries( + repoDir: string, + targetDir: string, + entries: TreeEntry[], +): Promise { + for (const entry of entries) { + const entryPath = path.join(targetDir, entry.path); + if (entry.type === "tree") { + await fs.promises.mkdir(entryPath, { recursive: true }); + const { tree } = await git.readTree({ fs, dir: repoDir, oid: entry.oid }); + await writeTreeEntries(repoDir, entryPath, tree); + } else if (entry.type === "blob") { + const { blob } = await git.readBlob({ + fs, + dir: repoDir, + oid: entry.oid, + }); + await fs.promises.writeFile(entryPath, blob, { + mode: entry.mode === "100755" ? 0o755 : 0o644, + }); + } + } +} + +/** + * Index a packfile and update a ref without materializing the working tree. + * + * Used by the hub to store state packs from sidecars where only the git + * object history matters, not the working-tree files. + * + * When `validateTree` is provided, the commit's top-level tree entries + * are checked after indexing but before the ref is promoted. Throws with + * a `"path_violation"` prefix if the validator rejects the tree. + * + * Returns the SHA the ref pointed at before this call (or `null` if the + * ref did not previously exist). Callers that drive `onRefUpdated`-style + * hooks should feed this value to the hook rather than performing a + * separate `resolveRef` read. + * + * `expectedOldSha` enforces a compare-and-set: the current ref value is + * read after the pack is indexed, compared to `expectedOldSha`, and the + * update aborts with `non_fast_forward:` on mismatch. Pass a SHA string + * to require the ref currently points there; pass `null` to require + * the ref does not yet exist. The caller is responsible for serializing + * concurrent updates to the same ref; this primitive enforces the CAS + * check but does not own the lock. + */ +/** + * Atomically publish a packfile into a git repository's pack directory. + * + * # Race being addressed + * + * `git.indexPack` writes the `.idx` file with a single non-atomic + * `fs.write` (see `_indexPack` at + * `node_modules/isomorphic-git/index.cjs:11953`). Pack discovery walks + * `objects/pack/` enumerating `*.idx` files non-recursively (see + * `readObjectPacked` at `node_modules/isomorphic-git/index.cjs:3394-3398` + * and the sibling enumerator at `:9286-9290`) and, for each one, + * derives the matching `.pack` filename and reads it. A concurrent + * reader landing during the `.idx` write can observe either a truncated + * buffer (surfacing as `TypeError: null is not an object (evaluating + * 'this.buffer.slice')` inside iso-git's `BufferCursor`) or an `.idx` + * that does not yet list the OID being looked for (surfacing as + * `NotFoundError`). + * + * # Invariant maintained + * + * At every point at which an `.idx` is visible to a reader's directory + * scan of `objects/pack/`, the `.pack` it references is fully written + * and findable under the filename the reader will derive from the + * `.idx`. Equivalently: readers see either no new pack at all, or the + * fully-published `.pack` + `.idx` pair. There is no observable + * intermediate state. + * + * # Strategy: stage outside the scanned directory + * + * The mid-write race is fundamental to `indexPack` writing its output + * file in-place. The only way to keep readers from observing it is to + * write the `.idx` somewhere readers do not scan. iso-git's enumerator + * scans only the literal path `/objects/pack` and only + * non-recursively (verified at the source line refs above), so any + * sibling directory under `objects/` is invisible to discovery. + * + * We stage in `objects/pack-staging//`. The per-transfer + * subdirectory keeps concurrent receives' temp pairs isolated from each + * other; the kernel guarantees that the staging directory and the + * final `objects/pack/` are on the same filesystem (they share a + * parent), so `fs.link` and `fs.rename` between them are atomic + * metadata operations. + * + * # Sequence (numbered for cross-reference with cleanup contract) + * + * 1. Create `objects/pack-staging//` and write the pack + * bytes to `pack.pack` inside it. Readers scanning + * `objects/pack/` do not see this file (different directory). + * + * 2. Run `git.indexPack` against the staging path — iso-git derives + * the `.idx` filename from the `.pack` filename and writes + * `pack.idx` next to it inside the staging directory. The + * non-atomic `.idx` write happens here but the file is in the + * staging directory, so concurrent readers scanning + * `objects/pack/` cannot observe the in-progress write. + * + * 3. Atomic publish (transitions readers from "no new pack" to "new + * pack visible"): + * + * a. `fs.link` staging `.pack` -> final `.pack`. The new + * `.pack` now exists in `objects/pack/`. Readers scanning + * for `.idx` files still do not see the new pack (no `.idx` + * for it in `objects/pack/` yet). The staging `.pack` + * directory entry is still present. + * + * b. `fs.rename` staging `.idx` -> final `.idx`. The new `.idx` + * appears atomically in `objects/pack/`; readers' next + * directory scan finds it and resolves the `.pack` written + * in 3a. The staging directory's `.idx` entry vanishes; + * since nothing was reading from the staging directory in + * the first place, this transition is observable only to + * this function. + * + * c. Remove the staging directory recursively (`unlink` of the + * remaining `.pack` plus `rmdir`). The final `.pack` inode + * persists via the link created in 3a. + * + * 4. On any throw before publish completes, recursively remove the + * staging directory. `fs.rm({recursive: true, force: true})` + * tolerates partial states (e.g. throw mid-write before `.idx` + * exists, or throw inside `indexPack`). + * + * # Cleanup contract + * + * On successful return, no staging files remain. On throw before + * publish (steps 1-2), the staging directory and any files inside it + * are removed. A throw partway through publish (between 3a and 3b) + * leaves a linked-but-unindexed `.pack` in `objects/pack/`; this is + * harmless (iso-git ignores `.pack` files with no matching `.idx`, + * verified at `index.cjs:3394-3398`) and the recovery path on the next + * call would re-write the same content. The cleanup-on-throw path + * here attempts to remove the orphan `.pack` but does not mask the + * original publish error. + * + * # Validation timing + * + * Callers that need to inspect the pack's contents (`git.readCommit`, + * `git.readTree`, signature verification against raw object bytes) + * must do so *after* this function returns: the pack lives in the + * staging directory until publish, so iso-git's pack discovery does + * not find it until step 3 completes. + * + * Callers that reject the published pack (signature failure, tree + * validator rejection, sha mismatch, CAS non-fast-forward) call + * `unpublishPack` to remove the published `.pack` + `.idx` pair from + * `objects/pack/`. POSIX semantics let a concurrent reader that + * already opened the rejected files finish its read against the + * cached bytes, and iso-git does not hold open descriptors between + * calls, so the unlink does not introduce the concurrent-read race + * that the staging strategy exists to eliminate. + * + * # Scope + * + * This helper does not own the lock that serializes concurrent + * receives on the same `dir`. Callers serialize at a higher layer + * (e.g. `withRepoLock` in + * `packages/hub-sessions/src/repo-store/store.ts`). The atomicity + * guarantee here is against arbitrary `dir`-level isomorphic-git reads + * issued from any code that shares the filesystem — including code + * that does not consult the caller's lock (e.g. tests that call + * `git.readCommit` directly). + * + * # Forward compatibility + * + * If isomorphic-git's `_indexPack` becomes atomic upstream (writes the + * `.idx` via temp+rename internally), or if pack discovery moves to a + * different mechanism, this staging dance becomes redundant and can + * collapse back to writing directly into `objects/pack/`. The cited + * source-line references above are the verification anchor for the + * next reader deciding whether the dance is still needed. + */ +export async function publishPackAtomically( + dir: string, + pack: Uint8Array, + transferId: string, +): Promise { + if (!SAFE_PATH_SEGMENT.test(transferId)) { + throw new Error( + `transferId contains unsafe characters: ${JSON.stringify(transferId)}`, + ); + } + + const packDir = path.join(dir, ".git", "objects", "pack"); + const stagingRoot = path.join(dir, ".git", "objects", STAGING_DIR_NAME); + const stagingDir = path.join(stagingRoot, transferId); + + await fs.promises.mkdir(packDir, { recursive: true }); + await fs.promises.mkdir(stagingDir, { recursive: true }); + + const stagingPackPath = path.join(stagingDir, "pack.pack"); + const stagingIdxPath = stagingPackPath.replace(/\.pack$/, ".idx"); + const { finalPackPath, finalIdxPath } = publishedPackPaths(dir, transferId); + // iso-git's indexPack takes a repo-root-relative filepath; derive it + // from the absolute path rather than rebuilding the segment list so + // the two stay coupled. + const stagingFilepath = path.relative(dir, stagingPackPath); + + let oids: string[]; + try { + // Step 1: write the pack bytes to the staging directory. + await fs.promises.writeFile(stagingPackPath, pack); + // Step 2: index the pack. iso-git writes the .idx next to the + // .pack — both stay inside the staging directory, invisible to + // any reader scanning objects/pack/. + const result = await git.indexPack({ + fs, + dir, + filepath: stagingFilepath, + }); + oids = result.oids; + } catch (err) { + // Step 4: cleanup before publish. Recursive rm handles every + // partial state (write succeeded but indexPack threw, etc.). The + // rm is wrapped so a secondary cleanup failure (permissions, I/O) + // does not mask the original error. + await fs.promises + .rm(stagingDir, { recursive: true, force: true }) + .catch(() => undefined); + throw err; + } + + // Step 3: atomic publish. The .pack link (3a) and the .idx rename + // (3b) are the two operations that determine externally-observable + // state; a failure inside this block means the publish is partial + // or absent, and the recovery rms below clear the half-published + // pack from objects/pack/. Staging-directory cleanup is NOT part of + // this block — see below. + try { + await fs.promises.link(stagingPackPath, finalPackPath); // 3a + await fs.promises.rename(stagingIdxPath, finalIdxPath); // 3b + } catch (err) { + // EEXIST on the link means `finalPackPath` already exists, which + // can only happen if a prior publishPackAtomically call on the + // same `dir` used the same `transferId`. The contract is that + // transferId is unique across all historical receives on `dir`; + // both production callers honour this (hub uses crypto.randomUUID, + // sidecar uses a per-process monotonic counter). Treat the EEXIST + // as a programmer error and surface it cleanly without running + // the recovery rms — those would destroy the earlier call's + // published pack and break any reader holding it. + if ( + err !== null && + typeof err === "object" && + "code" in err && + err.code === "EEXIST" + ) { + await fs.promises + .rm(stagingDir, { recursive: true, force: true }) + .catch(() => undefined); + throw new Error( + `transferId "${transferId}" already published in ${dir}; callers must guarantee transferId uniqueness across all historical receives`, + { cause: err }, + ); + } + // Partial-publish recovery: if 3a succeeded and 3b failed we + // leave a linked-but-unindexed .pack in objects/pack/. The rm of + // finalPackPath clears it; finalIdxPath cannot exist here (3b + // never completed), so no rm is needed for it. Each recovery rm + // is wrapped so a secondary failure (permissions, I/O) does not + // mask the original publish error — the caller needs to see the + // publish failure, not whatever the cleanup tripped on. + await fs.promises + .rm(stagingDir, { recursive: true, force: true }) + .catch(() => undefined); + await fs.promises.rm(finalPackPath, { force: true }).catch(() => undefined); + throw err; + } + + // Step 3c: staging-directory cleanup runs only after the pack is + // fully published. A failure here is benign from the caller's + // perspective — the pack is observable to readers, the publish + // succeeded — so the rm is wrapped and swallowed. It MUST NOT live + // inside the publish try/catch: doing so would let an + // EACCES/EBUSY/EIO on the staging rm trigger the recovery rms above + // and delete a pack that was already published and (potentially) + // already read by concurrent observers. A leftover staging + // directory on rm failure leaks disk until external cleanup; that + // is strictly preferable to retroactively destroying a successful + // publish. + await fs.promises + .rm(stagingDir, { recursive: true, force: true }) + .catch(() => undefined); + + return oids; +} + +export async function receivePackObjects( + dir: string, + pack: Uint8Array, + ref: string, + expectedSha: string, + transferId: string, + expectedOldSha: string | null, + validateTree?: TreeValidator, +): Promise { + const oids = await publishPackAtomically(dir, pack, transferId); + + // Post-publish validation runs inside a try so any rejection path + // (sha mismatch, CAS non-fast-forward, tree validator) unpublishes + // the pack before re-throwing. This removes a rejected pack + // immediately, at the moment of rejection. Write-path GC (`runGC`) + // also reclaims unreferenced packs, but only when a later accepted + // write crosses its threshold; the immediate unpublish keeps a flood + // of rejected packs from accumulating in the window before that. + try { + if (!oids.includes(expectedSha)) { + throw new Error( + `sha_mismatch: expected commit ${expectedSha} not found in pack`, + ); + } + + const currentOldSha = await git + .resolveRef({ fs, dir, ref }) + .catch(() => null); + + if (currentOldSha !== expectedOldSha) { + const observed = currentOldSha === null ? "null" : currentOldSha; + const expected = expectedOldSha === null ? "null" : expectedOldSha; + throw new Error( + `non_fast_forward: ref ${ref} expected ${expected} but found ${observed}`, + ); + } + + if (validateTree !== undefined) { + const { commit } = await git.readCommit({ + fs, + dir, + oid: expectedSha, + }); + const { tree } = await git.readTree({ + fs, + dir, + oid: commit.tree, + }); + // Surface every top-level tree entry — directories and files — + // to the kind handler. The writeTree path already passes both; + // the receivePack path used to filter to directories only, which + // hid top-level files (e.g. an extra `evil.exe` at the root, or + // a stray `package-registry.json`) from handlers that reject + // anything outside their allowlist. Handlers that need to + // distinguish file from directory inspect the entries + // themselves via `readBlob` / `listDir`; widening here makes the + // allowlist real on this path. + const topLevelPaths = tree.map((e) => e.path); + const readBlob = async (relPath: string): Promise => { + const segments = relPath.split("/"); + let currentTree = tree; + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]; + const entry = currentTree.find((e) => e.path === segment); + if (entry === undefined || entry.type !== "tree") { + throw new Error( + `readBlob: path ${relPath} not found in commit ${expectedSha} tree`, + ); + } + const next = await git.readTree({ fs, dir, oid: entry.oid }); + currentTree = next.tree; + } + const last = segments[segments.length - 1]; + const blobEntry = currentTree.find((e) => e.path === last); + if (blobEntry === undefined || blobEntry.type !== "blob") { + throw new Error( + `readBlob: path ${relPath} not found in commit ${expectedSha} tree`, + ); + } + const { blob } = await git.readBlob({ fs, dir, oid: blobEntry.oid }); + return blob; + }; + const listDir = async (relPath: string): Promise => { + if (relPath === "") { + return tree.map((e) => e.path); + } + let currentTree = tree; + for (const segment of relPath.split("/")) { + const entry = currentTree.find((e) => e.path === segment); + if (entry === undefined || entry.type !== "tree") { + throw new Error( + `listDir: path ${relPath} is not a directory in commit ${expectedSha} tree`, + ); + } + const next = await git.readTree({ fs, dir, oid: entry.oid }); + currentTree = next.tree; + } + return currentTree.map((e) => e.path); + }; + const verdict = await validateTree(topLevelPaths, readBlob, listDir); + if (verdict !== true) { + const reason = + typeof verdict === "object" + ? verdict.reason + : `commit ${expectedSha} tree contains disallowed paths: ${topLevelPaths.join(", ")}`; + throw new Error(`path_violation: ${reason}`); + } + } + + // Ref write happens after publish and validation so the ref never + // references a commit whose pack is unpublished or whose tree was + // rejected. + await git.writeRef({ fs, dir, ref, value: expectedSha, force: true }); + return currentOldSha; + } catch (err) { + await unpublishPack(dir, transferId); + throw err; + } +} + +/** + * Apply a git packfile to a repository, check out the working tree, and + * update the ref. + * + * Writes the pack to .git/objects/pack/ (the standard location for git + * packfiles), creates the .idx index via indexPack, checks out the tree to + * the working directory, then updates `ref` to point at `expectedSha`. + * The ref is written last so it never points at a commit whose working tree + * has not been materialized. + * + * When `verifyCommit` is provided, the commit's embedded signature is + * verified before the working tree is materialized. Throws with a message + * prefixed by `"signature_unsigned"` if the commit has no signature, or + * `"signature_invalid"` if verification fails. Only omit `verifyCommit` + * for state packs that follow their own signing model. + * + * Throws if the expected commit is not found in the pack. + * + * The caller is responsible for ensuring `dir` is an initialized git repo. + */ +export async function applyPack( + dir: string, + pack: Uint8Array, + ref: string, + expectedSha: string, + transferId: string, + verifyCommit?: CommitVerifier, +): Promise { + // The deploy apply shares the agent repo's object store with the + // reactor's context commits, the mail-audit commits, and GC. Hold the + // per-directory lock across the publish, validation, checkout, and ref + // write so none of them interleave with this apply. + await withRepoDirLock(dir, async () => { + const oids = await publishPackAtomically(dir, pack, transferId); + + // Post-publish validation runs inside a try so any rejection path + // (sha mismatch, missing signature, signature failure) unpublishes + // the pack before re-throwing. Sidecar `applyPack` is the last line + // of defence against a compromised hub or transport; the unpublish + // removes a rejected pack at the moment of rejection, before the + // reactor's write-path GC would next reclaim it, so a flood of + // rejected-signature packs cannot accumulate in the meantime. + try { + if (!oids.includes(expectedSha)) { + throw new Error( + `sha_mismatch: expected commit ${expectedSha} not found in pack`, + ); + } + + if (verifyCommit !== undefined) { + const { commit } = await git.readCommit({ + fs, + dir, + oid: expectedSha, + }); + if (commit.gpgsig === undefined) { + throw new Error( + `signature_unsigned: commit ${expectedSha} has no signature`, + ); + } + + // Reconstruct the signing payload from the raw object bytes. + // readCommit().payload is unreliable for SSH signatures because + // isogit's withoutSignature() only handles PGP armor markers. + const { object: rawBytes } = await readRawObject(dir, expectedSha); + const payload = stripGpgsig(new TextDecoder().decode(rawBytes)); + + if (!(await verifyCommit(payload, commit.gpgsig))) { + throw new Error( + `signature_invalid: commit ${expectedSha} signature verification failed`, + ); + } + } + + // Checkout reads from the now-published pack; ref is written last + // so it never references a commit whose working tree is not + // materialized. + await checkoutTree(dir, expectedSha, ref); + await git.writeRef({ fs, dir, ref, value: expectedSha, force: true }); + } catch (err) { + await unpublishPack(dir, transferId); + throw err; + } + }); +} diff --git a/vendor/intx-storage-isogit/src/pack-send.test.ts b/vendor/intx-storage-isogit/src/pack-send.test.ts new file mode 100644 index 000000000..06287d4ee --- /dev/null +++ b/vendor/intx-storage-isogit/src/pack-send.test.ts @@ -0,0 +1,312 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import git from "isomorphic-git"; +import { createDeployPack, createNegotiatedPack } from "./pack-send"; +import { collectReachableObjects } from "./object-walk"; +import { applyPack } from "./pack-receive"; +import { initAgentRepo } from "./init"; + +const tempDirs: string[] = []; + +async function tempDir(): Promise { + const d = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "interchange-test-"), + ); + tempDirs.push(d); + return d; +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })), + ); +}); + +async function makeDeployRepo(): Promise { + const dir = await tempDir(); + await git.init({ fs, dir, defaultBranch: "main" }); + + await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); + await fs.promises.writeFile( + path.join(dir, "deploy", "prompt.txt"), + "You are a helpful agent.", + ); + await fs.promises.writeFile( + path.join(dir, "deploy", "metadata.json"), + JSON.stringify({ version: "1" }), + ); + await git.add({ fs, dir, filepath: "deploy/prompt.txt" }); + await git.add({ fs, dir, filepath: "deploy/metadata.json" }); + await git.commit({ + fs, + dir, + message: "Initial deploy tree", + author: { name: "Hub", email: "hub@interchange.dev" }, + }); + + return dir; +} + +describe("createDeployPack", () => { + test("produces a packfile from a repo with a deploy tree", async () => { + const sourceDir = await makeDeployRepo(); + const { pack, commitSha } = await createDeployPack( + sourceDir, + "refs/heads/main", + ); + + expect(pack.length).toBeGreaterThan(0); + expect(commitSha.length).toBe(40); + }); + + test("produced pack can be applied to a fresh agent repo", async () => { + const sourceDir = await makeDeployRepo(); + const { pack, commitSha } = await createDeployPack( + sourceDir, + "refs/heads/main", + ); + + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await applyPack(targetDir, pack, "refs/heads/deploy", commitSha, "t1"); + + const resolved = await git.resolveRef({ + fs, + dir: targetDir, + ref: "refs/heads/deploy", + }); + expect(resolved).toBe(commitSha); + }); + + test("throws for a nonexistent ref", async () => { + const dir = await makeDeployRepo(); + await expect( + createDeployPack(dir, "refs/heads/nonexistent"), + ).rejects.toThrow(); + }); +}); + +const AUTHOR = { name: "Test", email: "test@test.dev" }; + +async function writeAndCommit( + dir: string, + files: { filepath: string; content: string }[], + message: string, +): Promise { + for (const { filepath, content } of files) { + const fullPath = path.join(dir, filepath); + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.promises.writeFile(fullPath, content); + await git.add({ fs, dir, filepath }); + } + return git.commit({ fs, dir, message, author: AUTHOR }); +} + +async function makeLinearRepo(): Promise<{ + dir: string; + c1: string; + c2: string; + c3: string; +}> { + const dir = await tempDir(); + await git.init({ fs, dir, defaultBranch: "main" }); + + const c1 = await writeAndCommit( + dir, + [{ filepath: "a.txt", content: "a-v1" }], + "first", + ); + const c2 = await writeAndCommit( + dir, + [{ filepath: "a.txt", content: "a-v2" }], + "second", + ); + const c3 = await writeAndCommit( + dir, + [{ filepath: "a.txt", content: "a-v3" }], + "third", + ); + + return { dir, c1, c2, c3 }; +} + +async function makeForkedRepo(): Promise<{ + dir: string; + base: string; + branchA: string; + branchB: string; +}> { + const dir = await tempDir(); + await git.init({ fs, dir, defaultBranch: "main" }); + + const base = await writeAndCommit( + dir, + [{ filepath: "base.txt", content: "base" }], + "base", + ); + + const branchA = await writeAndCommit( + dir, + [{ filepath: "a.txt", content: "alpha" }], + "branch A", + ); + + // Reset working tree by removing branch-A artifact, then commit branch B + // on top of base. We use git.commit with explicit parent via writeCommit + // semantics through a fresh branch. + await git.writeRef({ + fs, + dir, + ref: "refs/heads/main", + value: base, + force: true, + }); + await fs.promises.rm(path.join(dir, "a.txt"), { force: true }); + await git.remove({ fs, dir, filepath: "a.txt" }).catch(() => undefined); + + const branchB = await writeAndCommit( + dir, + [{ filepath: "b.txt", content: "bravo" }], + "branch B", + ); + + // Restore branch A as a named ref so callers can reach it. + await git.writeRef({ + fs, + dir, + ref: "refs/heads/branch-a", + value: branchA, + force: true, + }); + await git.writeRef({ + fs, + dir, + ref: "refs/heads/branch-b", + value: branchB, + force: true, + }); + + return { dir, base, branchA, branchB }; +} + +describe("createNegotiatedPack", () => { + test("single want, no haves: includes every reachable object", async () => { + const { dir, c3 } = await makeLinearRepo(); + + const result = await createNegotiatedPack(dir, [c3], []); + if (result === null) throw new Error("expected a non-empty pack"); + + expect(result.oids).toContain(c3); + // Three commits, three trees, three blobs (a.txt v1/v2/v3). + expect(result.oids.length).toBe(9); + }); + + test("single want with HEAD have: produces an empty (null) pack", async () => { + const { dir, c3 } = await makeLinearRepo(); + + const result = await createNegotiatedPack(dir, [c3], [c3]); + expect(result).toBeNull(); + }); + + test("single want with ancestor have: subtracts ancestor's objects", async () => { + const { dir, c1, c3 } = await makeLinearRepo(); + + const full = await createNegotiatedPack(dir, [c3], []); + if (full === null) throw new Error("expected full pack"); + const negotiated = await createNegotiatedPack(dir, [c3], [c1]); + if (negotiated === null) throw new Error("expected negotiated pack"); + + expect(negotiated.oids.length).toBeLessThan(full.oids.length); + expect(negotiated.oids).toContain(c3); + expect(negotiated.oids).not.toContain(c1); + }); + + test("multi-want with overlapping haves: union of reachable minus union of have-reachable", async () => { + const { dir, base, branchA, branchB } = await makeForkedRepo(); + + const result = await createNegotiatedPack(dir, [branchA, branchB], [base]); + if (result === null) throw new Error("expected a non-empty pack"); + + expect(result.oids).toContain(branchA); + expect(result.oids).toContain(branchB); + expect(result.oids).not.toContain(base); + }); + + test("includeSha filter drops oids that the predicate rejects", async () => { + const { dir, c3 } = await makeLinearRepo(); + + const seen: string[] = []; + const result = await createNegotiatedPack(dir, [c3], [], (oid) => { + seen.push(oid); + return oid !== c3; + }); + if (result === null) throw new Error("expected pack"); + + expect(seen.length).toBeGreaterThan(0); + expect(result.oids).not.toContain(c3); + }); + + test("throws when wants is empty", async () => { + const { dir } = await makeLinearRepo(); + await expect(createNegotiatedPack(dir, [], [])).rejects.toThrow( + "wants must be non-empty", + ); + }); + + test("silently ignores unknown haves", async () => { + const { dir, c3 } = await makeLinearRepo(); + const bogusHave = "0".repeat(40); + + const result = await createNegotiatedPack(dir, [c3], [bogusHave]); + if (result === null) throw new Error("expected pack"); + + expect(result.oids).toContain(c3); + }); + + test("precomputed wantedObjects matches the from-scratch path byte-for-byte", async () => { + // Callers that pre-walk reachable-from-wants for their own + // purposes can hand the set in via `options.wantedObjects`. The + // result must be identical to letting `createNegotiatedPack` + // recompute the set internally — same OIDs in the same order, + // same pack bytes — so the optimization is invisible to + // downstream consumers. + const { dir, c1, c3 } = await makeLinearRepo(); + + const fromScratch = await createNegotiatedPack(dir, [c3], [c1]); + if (fromScratch === null) throw new Error("expected pack"); + + // Walk reachable-from-wants ourselves to construct the + // precomputed set the caller would hand in. We use the same + // helper `createNegotiatedPack` uses internally to keep the + // contract obvious: precomputed must equal what the function + // would have computed. + const wantedObjects = new Set(); + const chainHead = await git.readCommit({ fs, dir, oid: c3 }); + const queue: string[] = [c3]; + const seen = new Set(); + while (queue.length > 0) { + const oid = queue.shift(); + if (oid === undefined) break; + if (seen.has(oid)) continue; + seen.add(oid); + const objs = await collectReachableObjects(dir, oid); + for (const o of objs) wantedObjects.add(o); + const c = oid === c3 ? chainHead : await git.readCommit({ fs, dir, oid }); + for (const p of c.commit.parent) { + if (!seen.has(p)) queue.push(p); + } + } + + const precomputed = await createNegotiatedPack(dir, [c3], [c1], undefined, { + wantedObjects, + }); + if (precomputed === null) throw new Error("expected pack"); + + expect(precomputed.oids).toEqual(fromScratch.oids); + expect(precomputed.pack).toEqual(fromScratch.pack); + }); +}); diff --git a/vendor/intx-storage-isogit/src/pack-send.ts b/vendor/intx-storage-isogit/src/pack-send.ts new file mode 100644 index 000000000..a38970f35 --- /dev/null +++ b/vendor/intx-storage-isogit/src/pack-send.ts @@ -0,0 +1,185 @@ +import fs from "node:fs"; +import git from "isomorphic-git"; +import { collectReachableObjects } from "./object-walk"; +import { withRepoDirLock } from "./repo-lock"; + +/** + * Create a git packfile containing all objects reachable from a ref. + * + * Used by the hub to produce deploy packs for transfer to sidecars, and by + * the sidecar to produce state packs. The caller sends the resulting bytes + * as chunked repo.pack.push frames. + */ +export async function createDeployPack( + dir: string, + ref: string, +): Promise<{ pack: Uint8Array; commitSha: string }> { + // Read under the per-directory lock so a concurrent GC pass cannot prune + // a loose object out from under the reachability walk or the pack write. + return withRepoDirLock(dir, async () => { + const commitSha = await git.resolveRef({ fs, dir, ref }); + const oids = await collectReachableObjects(dir, commitSha); + + const result = await git.packObjects({ + fs, + dir, + oids, + write: false, + }); + if (result.packfile === undefined) { + throw new Error( + `packObjects returned no packfile for ref "${ref}" (${commitSha})`, + ); + } + + return { pack: result.packfile, commitSha }; + }); +} + +/** + * Predicate used by `createNegotiatedPack` to filter the set of object OIDs + * actually placed in the resulting packfile. The walker computes the + * full set of objects reachable from the wants and not reachable from any + * have; the caller's `includeSha(sha)` then filters that set on a per-oid + * basis. Returning `true` keeps the object; returning `false` drops it. + * + * Used by the upload-pack route to suppress objects that are reachable + * only via refs the requester is not permitted to fetch (route-level + * reachability enforcement). + */ +export type IncludeShaPredicate = (sha: string) => boolean | Promise; + +async function collectCommitChain( + dir: string, + start: string, +): Promise { + const seen = new Set(); + const queue: string[] = [start]; + while (queue.length > 0) { + const oid = queue.shift(); + if (oid === undefined) break; + if (seen.has(oid)) continue; + seen.add(oid); + const { commit } = await git.readCommit({ fs, dir, oid }); + for (const parent of commit.parent) { + if (!seen.has(parent)) queue.push(parent); + } + } + return [...seen]; +} + +async function reachableFromCommits( + dir: string, + commits: readonly string[], +): Promise> { + const reachable = new Set(); + for (const commitOid of commits) { + const chain = await collectCommitChain(dir, commitOid); + for (const ancestor of chain) { + if (reachable.has(ancestor)) continue; + const objects = await collectReachableObjects(dir, ancestor); + for (const oid of objects) { + reachable.add(oid); + } + } + } + return reachable; +} + +/** + * Build a packfile from a multi-want, multi-have negotiation. + * + * The walker computes the set of objects reachable from any commit in + * `wants`, then subtracts the set of objects reachable from any commit in + * `haves`. The remaining objects — those the requester needs but doesn't + * already have — are run through `includeSha(sha)` for per-oid filtering + * (used by the upload-pack route to drop objects that are only reachable + * via refs the requester is not permitted to fetch), and the surviving + * OIDs are handed to `git.packObjects`. + * + * `haves` may include OIDs that don't exist locally; unknown commits are + * silently ignored, matching the smart-HTTP semantics where the client + * may advertise haves it hasn't actually verified the server has. + * + * Returns `null` when the resulting object set is empty (no objects to + * send) — callers should treat this as "everything you asked for, you + * already have." + */ +export type CreateNegotiatedPackOptions = { + /** + * Precomputed set of object OIDs reachable from `wants`. Supplied by + * callers that already walked this set for their own purposes — the + * upload-pack route layer pre-walks the allowed-ref tree to enforce + * the bearer token's refPattern, then folds the want walk into the + * same pass so the work is not duplicated here. + * + * Contract: when set, this MUST equal + * `reachableFromCommits(dir, wants)`. A superset over-packs (sending + * objects the client did not ask for); a subset under-packs + * (omitting objects the client needs). Callers that omit this option + * pay one walk inside `createNegotiatedPack`; either way the byte + * output is identical for the same `(wants, haves, includeSha)` + * triple. + */ + wantedObjects?: ReadonlySet; +}; + +export async function createNegotiatedPack( + dir: string, + wants: readonly string[], + haves: readonly string[], + includeSha?: IncludeShaPredicate, + options?: CreateNegotiatedPackOptions, +): Promise<{ pack: Uint8Array; oids: string[] } | null> { + if (wants.length === 0) { + throw new Error("createNegotiatedPack: wants must be non-empty"); + } + + const wantedObjects = + options?.wantedObjects ?? (await reachableFromCommits(dir, wants)); + + const knownHaves: string[] = []; + for (const have of haves) { + try { + await git.readCommit({ fs, dir, oid: have }); + knownHaves.push(have); + } catch { + // Unknown have — the client's advertised state is not present + // locally. Skip without failing the negotiation. + } + } + + const haveObjects = await reachableFromCommits(dir, knownHaves); + + const candidates: string[] = []; + for (const oid of wantedObjects) { + if (haveObjects.has(oid)) continue; + candidates.push(oid); + } + + let oids: string[]; + if (includeSha === undefined) { + oids = candidates; + } else { + oids = []; + for (const oid of candidates) { + if (await includeSha(oid)) oids.push(oid); + } + } + + if (oids.length === 0) return null; + + const result = await git.packObjects({ + fs, + dir, + oids, + write: false, + }); + if (result.packfile === undefined) { + throw new Error( + `packObjects returned no packfile for ${oids.length.toString()} oids`, + ); + } + + return { pack: result.packfile, oids }; +} diff --git a/vendor/intx-storage-isogit/src/peek-turns.test.ts b/vendor/intx-storage-isogit/src/peek-turns.test.ts new file mode 100644 index 000000000..23c6cfcaa --- /dev/null +++ b/vendor/intx-storage-isogit/src/peek-turns.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createIsogitStore } from "./index"; +import type { ConversationTurn, TokenUsage } from "@intx/types/runtime"; + +const ZERO_USAGE: TokenUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, +}; + +const tempDirs: string[] = []; +async function tempDir(): Promise { + const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), "peek-turns-")); + tempDirs.push(d); + return d; +} +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })), + ); +}); + +// Kept local: pure ConversationTurn constructor. Importing +// @intx/inference-testing would add an inference/discovery test graph +// for a package that never uses the harness. +function userTurn(text: string): ConversationTurn { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +describe("DurableMirrorReads.peekTurns", () => { + test("returns [] before any writeTurns", async () => { + const store = await createIsogitStore(await tempDir()); + expect(store.peekTurns()).toEqual([]); + }); + + test("returns the exact array reference handed to writeTurns", async () => { + const store = await createIsogitStore(await tempDir()); + const arr: ConversationTurn[] = [userTurn("a")]; + await store.writeTurns(arr); + expect(store.peekTurns()).toBe(arr); // same reference, not a copy + }); + + test("aliases the caller's array: in-place mutation is visible", async () => { + const store = await createIsogitStore(await tempDir()); + const arr: ConversationTurn[] = [userTurn("a")]; + await store.writeTurns(arr); + arr.push(userTurn("b")); // the reactor's appendTurn does exactly this + // peekTurns now reports 2 turns though only 1 was persisted to disk. + expect(store.peekTurns()).toHaveLength(2); + const onDisk = await store.load(); + expect(onDisk.turns).toHaveLength(1); + }); + + test("peekTurns tracks the last writeTurns array", async () => { + const store = await createIsogitStore(await tempDir()); + const first: ConversationTurn[] = [userTurn("first")]; + await store.writeTurns(first); + const second: ConversationTurn[] = [ + userTurn("second-a"), + userTurn("second-b"), + ]; + await store.writeTurns(second); + expect(store.peekTurns()).toBe(second); + }); +}); + +describe("DurableMirrorReads.loadMetadata", () => { + test("returns fresh empty defaults when metadata.json is absent", async () => { + const store = await createIsogitStore(await tempDir()); + const m1 = await store.loadMetadata(); + expect(m1.pendingOperations).toEqual([]); + expect(m1.tokenUsage).toEqual(ZERO_USAGE); + expect(m1.connectorState).toBeNull(); + // A fresh copy each call: distinct references, and mutating one + // result must not leak into the next. + m1.tokenUsage.input = 999; + const m2 = await store.loadMetadata(); + expect(m2.tokenUsage).toEqual(ZERO_USAGE); + expect(m2.pendingOperations).toEqual([]); + expect(m2.pendingOperations).not.toBe(m1.pendingOperations); + }); + + test("load and loadMetadata agree after writeMetadata", async () => { + const store = await createIsogitStore(await tempDir()); + const usage: TokenUsage = { + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }; + await store.writeMetadata({ pendingOperations: [], tokenUsage: usage }); + const viaLoad = await store.load(); + const viaMeta = await store.loadMetadata(); + expect(viaMeta.tokenUsage).toEqual(usage); + expect(viaLoad.tokenUsage).toEqual(usage); + expect(viaLoad.pendingOperations).toEqual(viaMeta.pendingOperations); + expect(viaLoad.connectorState).toEqual(viaMeta.connectorState); + }); +}); diff --git a/vendor/intx-storage-isogit/src/repo-disk.ts b/vendor/intx-storage-isogit/src/repo-disk.ts new file mode 100644 index 000000000..77e0575d4 --- /dev/null +++ b/vendor/intx-storage-isogit/src/repo-disk.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import path from "node:path"; +import git from "isomorphic-git"; + +/** + * Disk-occupancy snapshot of an agent repo's `.git` directory. Drives the + * write-path GC trigger (pack count crossing a threshold) and the + * disk-pressure observability warning (byte size crossing a threshold). + */ +export type RepoDiskUsage = { + gitBytes: number; + packCount: number; + looseObjectCount: number; +}; + +/** + * Count the immediate child entries of `dir`. Returns 0 when the directory + * does not exist (a repo subtree that has not been created yet) -- absence + * is a real zero, not an error to surface. + */ +function countDirEntries(dir: string): number { + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch (cause) { + if ( + cause instanceof Error && + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + return 0; + } + throw cause; + } + return entries.length; +} + +/** + * Count loose git objects under `.git/objects//`. Loose objects are the + * un-packed per-commit objects isomorphic-git writes on each commit; their + * count rising and collapsing after a repack is the pack-growth signature a + * GC pass reclaims. The two-hex-char fan-out dirs plus `pack`/`info` are the + * only children of `objects/`; the latter two are skipped. + */ +export function countLooseObjects(repoDir: string): number { + const objectsDir = path.join(repoDir, ".git", "objects"); + let fanoutDirs: string[]; + try { + fanoutDirs = fs.readdirSync(objectsDir); + } catch (cause) { + if ( + cause instanceof Error && + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + return 0; + } + throw cause; + } + let total = 0; + for (const name of fanoutDirs) { + if (name === "pack" || name === "info") continue; + // Loose-object fan-out dirs are exactly two lowercase hex chars. + if (!/^[0-9a-f]{2}$/.test(name)) continue; + total += countDirEntries(path.join(objectsDir, name)); + } + return total; +} + +/** + * Count `.pack` files under `.git/objects/pack/`. Each accepted receive + * publishes one pack and the prior tip's pack is never reclaimed without a + * GC pass, so the pack count is the monotonic accumulation a write-path GC + * trigger watches. + */ +export function countPackFiles(repoDir: string): number { + const packDir = path.join(repoDir, ".git", "objects", "pack"); + let entries: string[]; + try { + entries = fs.readdirSync(packDir); + } catch (cause) { + if ( + cause instanceof Error && + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + return 0; + } + throw cause; + } + return entries.filter((name) => name.endsWith(".pack")).length; +} + +/** + * Total byte size of the repo's `.git` directory (loose + pack + refs + + * logs). A coarse repo-size proxy for the disk-pressure warning. Walks the + * tree with `fs.lstatSync`; bounded by the repo size, which is the thing + * being measured. + */ +export function gitBytes(repoDir: string): number { + const gitDir = path.join(repoDir, ".git"); + let total = 0; + const stack: string[] = [gitDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) break; + let stat: fs.Stats; + try { + stat = fs.lstatSync(current); + } catch (cause) { + if ( + cause instanceof Error && + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + continue; + } + throw cause; + } + if (stat.isDirectory()) { + for (const child of fs.readdirSync(current)) { + stack.push(path.join(current, child)); + } + } else if (stat.isFile()) { + total += stat.size; + } + } + return total; +} + +/** + * The object counts alone, without the full `.git` byte walk. The + * write-path GC trigger samples these on every write, so they must stay + * cheap — two directory reads — and leave the recursive byte walk + * (`gitBytes`) for the infrequent reclaim path. + */ +export type RepoObjectCounts = { + packCount: number; + looseObjectCount: number; +}; + +export function repoObjectCounts(dir: string): RepoObjectCounts { + return { + packCount: countPackFiles(dir), + looseObjectCount: countLooseObjects(dir), + }; +} + +/** + * Snapshot the GC-relevant disk counters for the agent repo at `dir`, + * including the full `.git` byte walk. + */ +export function repoDiskUsage(dir: string): RepoDiskUsage { + return { + gitBytes: gitBytes(dir), + ...repoObjectCounts(dir), + }; +} + +/** + * Resolve every local branch ref of the repo at `dir` to its tip SHA. + * + * Agent repos carry two diverging heads (`refs/heads/main` and + * `refs/heads/deploy`); GC must union reachability across all of them, so it + * enumerates them here rather than assuming a single ref. No tags or other + * ref namespaces are created in these repos, so listing branches is + * complete. + */ +export async function listRepoRefs( + dir: string, +): Promise<{ ref: string; oid: string }[]> { + const branches = await git.listBranches({ fs, dir }); + const refs: { ref: string; oid: string }[] = []; + for (const branch of branches) { + const ref = `refs/heads/${branch}`; + const oid = await git.resolveRef({ fs, dir, ref }); + refs.push({ ref, oid }); + } + return refs; +} diff --git a/vendor/intx-storage-isogit/src/repo-lock.ts b/vendor/intx-storage-isogit/src/repo-lock.ts new file mode 100644 index 000000000..859ce62a4 --- /dev/null +++ b/vendor/intx-storage-isogit/src/repo-lock.ts @@ -0,0 +1,52 @@ +import { resolve } from "node:path"; + +/** + * Process-wide per-directory serialization for agent-repo object-store + * mutations. Keyed by the lexically-resolved absolute path of the repo + * working directory. + * + * On the sidecar a single agent repo is written by several independent + * drivers — the reactor's context commits, the mail-audit commits, deploy + * pack applies — and read by the state-pack producer, none of which share a + * higher-level lock. Garbage collection prunes loose objects and packs, so + * it cannot run concurrently with any of them without risking the deletion + * of an object a writer just produced. This lock makes the storage layer + * the single owner of that constraint: every mutator and the collector + * acquire it, so they run one-at-a-time per directory and GC observes a + * quiescent object store. + * + * Single-process only — it serializes operations issued from one process, + * not across a second process or an external git client touching the same + * directory. The hub's higher-level `withRepoLock` already serializes its + * own write paths; this lock nests harmlessly underneath it (the hub never + * holds this lock while acquiring its own, so the acquisition order is + * always outer-to-inner and cannot deadlock). + * + * Each entry holds the tail of the chain of in-flight critical sections for + * that directory; the next acquirer awaits the current tail and replaces it + * with its own pending completion. The tail-check on release prevents the + * map from leaking entries once a directory's chain drains. + */ +const locks = new Map>(); + +export async function withRepoDirLock( + dir: string, + fn: () => Promise, +): Promise { + const key = resolve(dir); + const previous = locks.get(key) ?? Promise.resolve(); + let releaseFn: () => void = () => undefined; + const tail = new Promise((res) => { + releaseFn = res; + }); + locks.set(key, tail); + try { + await previous; + return await fn(); + } finally { + if (locks.get(key) === tail) { + locks.delete(key); + } + releaseFn(); + } +} diff --git a/vendor/intx-storage-isogit/src/signer.ts b/vendor/intx-storage-isogit/src/signer.ts new file mode 100644 index 000000000..6261b5011 --- /dev/null +++ b/vendor/intx-storage-isogit/src/signer.ts @@ -0,0 +1,8 @@ +/** + * A function that signs a git commit payload and returns the armored + * signature string for embedding in the gpgsig header. + * + * Callers bind this to their signing implementation (e.g. createSSHSignature + * with an Ed25519 key pair). The store does not own key material. + */ +export type CommitSigner = (payload: string) => Promise; diff --git a/vendor/intx-storage-isogit/src/store.test.ts b/vendor/intx-storage-isogit/src/store.test.ts new file mode 100644 index 000000000..6f1e90393 --- /dev/null +++ b/vendor/intx-storage-isogit/src/store.test.ts @@ -0,0 +1,1337 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import git from "isomorphic-git"; +import { + createIsogitStore, + currentBranch, + createAndSwitchBranch, + switchBranch, + listBranches, + logHistory, +} from "./index"; +import { base64Encode } from "@intx/types"; +import { IsogitStore } from "./store"; +import { initAgentRepo } from "./init"; +import type { + AssistantTurn, + ConversationTurn, + PendingOperation, + TokenUsage, + TransformRecord, +} from "@intx/types/runtime"; +import type { AuditRecord, ErrorRecord } from "@intx/types/audit"; + +const ZERO_USAGE: TokenUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, +}; + +async function makeTempDir(): Promise { + return fs.promises.mkdtemp(path.join(os.tmpdir(), "interchange-test-")); +} + +const tempDirs: string[] = []; + +async function tempDir(): Promise { + const d = await makeTempDir(); + tempDirs.push(d); + return d; +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })), + ); +}); + +describe("createIsogitStore", () => { + test("initializes a new agent repo and returns a usable store", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + const { turns, pendingOperations, tokenUsage } = await store.load(); + + expect(turns).toEqual([]); + expect(pendingOperations).toEqual([]); + expect(tokenUsage).toEqual(ZERO_USAGE); + }); + + test("is idempotent — calling twice returns a working store", async () => { + const dir = await tempDir(); + await createIsogitStore(dir); + const store = await createIsogitStore(dir); + const { turns } = await store.load(); + expect(turns).toEqual([]); + }); +}); + +describe("save and load round-trip", () => { + test("messages survive a commit/load cycle", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const messages: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + timestamp: 1000, + }, + { + role: "assistant", + content: [{ type: "text", text: "Hi there" }], + model: "test-model", + timestamp: 2000, + }, + ]; + + await store.writeTurns(messages); + + await store.commit({ message: "first checkpoint" }); + const { turns: loaded } = await store.load(); + + expect(loaded).toEqual(messages); + }); + + test("multiple commits accumulate correctly", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const first: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "step 1" }], + timestamp: 1000, + }, + ]; + const second: ConversationTurn[] = [ + ...first, + { + role: "assistant", + content: [{ type: "text", text: "step 2" }], + model: "m", + timestamp: 2000, + }, + ]; + + await store.writeTurns(first); + + await store.commit({ message: "step 1" }); + await store.writeTurns(second); + + await store.commit({ message: "step 2" }); + + const { turns } = await store.load(); + expect(turns).toEqual(second); + }); +}); + +describe("checkpoint creates commit", () => { + test("commit returns a ContextCommit with correct metadata", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([]); + + const commitResult = await store.commit({ message: "named checkpoint" }); + + expect(typeof commitResult.hash).toBe("string"); + expect(commitResult.hash.length).toBeGreaterThan(0); + expect(commitResult.message).toBe("named checkpoint"); + expect(typeof commitResult.timestamp).toBe("number"); + expect(commitResult.timestamp).toBeGreaterThan(0); + }); + + test("log contains the checkpoint after commit", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([]); + + await store.commit({ message: "my checkpoint" }); + const entries = await store.log(5); + + const found = entries.find((e) => e.message === "my checkpoint"); + expect(found).toBeDefined(); + if (!found) throw new Error("unreachable"); + expect(found.hash.length).toBeGreaterThan(0); + }); +}); + +describe("branch operations", () => { + test("createAndSwitchBranch creates and activates a new branch", async () => { + const dir = await tempDir(); + await createIsogitStore(dir); + + await createAndSwitchBranch(dir, "feature-branch"); + const branch = await currentBranch(dir); + + expect(branch).toBe("feature-branch"); + }); + + test("changes on a branch are isolated from main", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const mainTurns: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "on main" }], + timestamp: 1000, + }, + ]; + await store.writeTurns(mainTurns); + + await store.commit({ message: "main work" }); + + await createAndSwitchBranch(dir, "experiment"); + + const branchTurns: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "on branch" }], + timestamp: 2000, + }, + ]; + await store.writeTurns(branchTurns); + + await store.commit({ message: "branch work" }); + + await switchBranch(dir, "main"); + + const { turns } = await store.load(); + expect(turns).toEqual(mainTurns); + }); + + test("listBranches includes main and created branches", async () => { + const dir = await tempDir(); + await createIsogitStore(dir); + + await createAndSwitchBranch(dir, "branch-a"); + await switchBranch(dir, "main"); + + const branches = await listBranches(dir); + expect(branches).toContain("main"); + expect(branches).toContain("branch-a"); + }); +}); + +describe("history log", () => { + test("log returns entries in reverse chronological order", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([]); + + await store.commit({ message: "commit one" }); + await store.writeTurns([]); + + await store.commit({ message: "commit two" }); + + const entries = await logHistory(dir, 5); + + const first = entries[0]; + const second = entries[1]; + if (!first || !second) throw new Error("unreachable"); + expect(first.message).toBe("commit two"); + expect(second.message).toBe("commit one"); + }); + + test("parentHash chains commits together", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([]); + + const first = await store.commit({ message: "first" }); + await store.writeTurns([]); + + const second = await store.commit({ message: "second" }); + + expect(second.parentHash).toBe(first.hash); + }); +}); + +describe("readAt", () => { + test("reads message history at an earlier commit", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const v1: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "version 1" }], + timestamp: 1000, + }, + ]; + await store.writeTurns(v1); + + const first = await store.commit({ message: "v1" }); + + const v2: ConversationTurn[] = [ + ...v1, + { + role: "assistant", + content: [{ type: "text", text: "version 2" }], + model: "m", + timestamp: 2000, + }, + ]; + await store.writeTurns(v2); + + await store.commit({ message: "v2" }); + + const atFirst = await store.readAt(first.hash); + expect(atFirst).toEqual(v1); + }); +}); + +function makeAuditRecord(overrides: Partial = {}): AuditRecord { + return { + callId: "call-1", + tool: "bash", + arguments: { cmd: "ls" }, + authz: null, + result: { content: "output", isError: false }, + timestamp: "2026-04-17T00:00:00.000Z", + sessionId: "session-1", + seq: 0, + ...overrides, + }; +} + +async function createAuditStore(dir: string): Promise { + await initAgentRepo(dir); + return new IsogitStore(dir); +} + +describe("audit store", () => { + test("round-trips a single audit record", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const record = makeAuditRecord(); + await store.commitAudit([record]); + + const loaded = await store.loadAudit("session-1"); + expect(loaded).toEqual([record]); + }); + + test("commits multiple records in a single batch", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const records = [ + makeAuditRecord({ callId: "c1", seq: 0 }), + makeAuditRecord({ callId: "c2", seq: 1 }), + makeAuditRecord({ callId: "c3", seq: 2 }), + ]; + await store.commitAudit(records); + + const loaded = await store.loadAudit("session-1"); + expect(loaded.length).toBe(3); + expect(loaded.map((r) => r.callId)).toEqual(["c1", "c2", "c3"]); + }); + + test("isolates records by session", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitAudit([ + makeAuditRecord({ sessionId: "s1", callId: "c1", seq: 0 }), + makeAuditRecord({ sessionId: "s2", callId: "c2", seq: 0 }), + ]); + + const s1 = await store.loadAudit("s1"); + const s2 = await store.loadAudit("s2"); + expect(s1.length).toBe(1); + const r1 = s1[0]; + if (r1 === undefined) throw new Error("expected s1 record"); + expect(r1.callId).toBe("c1"); + expect(s2.length).toBe(1); + const r2 = s2[0]; + if (r2 === undefined) throw new Error("expected s2 record"); + expect(r2.callId).toBe("c2"); + }); + + test("returns empty array for nonexistent session", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const loaded = await store.loadAudit("no-such-session"); + expect(loaded).toEqual([]); + }); + + test("sorts loaded records by seq", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitAudit([ + makeAuditRecord({ callId: "c3", seq: 5 }), + makeAuditRecord({ callId: "c1", seq: 1 }), + makeAuditRecord({ callId: "c2", seq: 3 }), + ]); + + const loaded = await store.loadAudit("session-1"); + expect(loaded.map((r) => r.seq)).toEqual([1, 3, 5]); + }); + + test("empty records array does not disturb existing records", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitAudit([makeAuditRecord({ callId: "c1", seq: 0 })]); + await store.commitAudit([]); + + const loaded = await store.loadAudit("session-1"); + expect(loaded.length).toBe(1); + expect(loaded[0]?.callId).toBe("c1"); + }); + + test("accumulates records across multiple commits", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitAudit([makeAuditRecord({ callId: "c1", seq: 0 })]); + await store.commitAudit([makeAuditRecord({ callId: "c2", seq: 1 })]); + + const loaded = await store.loadAudit("session-1"); + expect(loaded.length).toBe(2); + expect(loaded.map((r) => r.callId)).toEqual(["c1", "c2"]); + }); + + test("preserves authz fields through round-trip", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const record = makeAuditRecord({ + authz: { + effect: "allow", + resolvedBy: { + id: "g1", + resource: "tool:bash", + action: "invoke", + effect: "allow", + origin: "creator", + specificity: 1009, + }, + matchingGrants: [ + { + id: "g1", + resource: "tool:bash", + action: "invoke", + effect: "allow", + origin: "creator", + specificity: 1009, + }, + ], + blocked: false, + }, + }); + await store.commitAudit([record]); + + const loaded = await store.loadAudit("session-1"); + const r = loaded[0]; + if (r === undefined) throw new Error("expected record"); + expect(r.authz).toEqual(record.authz); + }); + + test("rejects corrupted audit files on load", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitAudit([makeAuditRecord()]); + + const corruptPath = path.join( + dir, + "state", + "audit", + "session-1", + "call-1.json", + ); + await fs.promises.writeFile(corruptPath, JSON.stringify({ garbage: true })); + + let thrown: Error | undefined; + try { + await store.loadAudit("session-1"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Invalid audit record"); + }); + + test("rejects sessionId with path traversal", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const record = makeAuditRecord({ sessionId: "../escape" }); + let thrown: Error | undefined; + try { + await store.commitAudit([record]); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("unsafe characters"); + }); + + test("rejects callId with path traversal", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const record = makeAuditRecord({ callId: "../escape" }); + let thrown: Error | undefined; + try { + await store.commitAudit([record]); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("unsafe characters"); + }); + + test("rejects sessionId with path traversal on load", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + let thrown: Error | undefined; + try { + await store.loadAudit("../escape"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("unsafe characters"); + }); + + test("rejects duplicate callId within a session", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitAudit([makeAuditRecord({ callId: "c1", seq: 0 })]); + let thrown: Error | undefined; + try { + await store.commitAudit([makeAuditRecord({ callId: "c1", seq: 1 })]); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Duplicate audit record"); + }); + + test("commitAudit duplicate in batch leaves no orphaned files", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitAudit([makeAuditRecord({ callId: "c1", seq: 0 })]); + + // Batch contains a new record and a duplicate. Pre-flight should + // reject before writing the new record to disk. + const fresh = makeAuditRecord({ callId: "c2", seq: 1 }); + const dup = makeAuditRecord({ callId: "c1", seq: 2 }); + let thrown: Error | undefined; + try { + await store.commitAudit([fresh, dup]); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Duplicate audit record"); + + const freshPath = path.join(dir, "state", "audit", "session-1", "c2.json"); + expect(fs.existsSync(freshPath)).toBe(false); + }); +}); + +function makeErrorRecord(overrides: Partial = {}): ErrorRecord { + return { + source: "inference", + category: "credential_failure", + message: "Authentication failed", + fatal: false, + timestamp: "2026-04-17T00:00:00.000Z", + sessionId: "session-1", + seq: 1, + ...overrides, + }; +} + +describe("error store", () => { + test("commitErrors writes error records to state/errors directory", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const record = makeErrorRecord(); + await store.commitErrors([record]); + + const expectedPath = path.join( + dir, + "state", + "errors", + "session-1", + "00000001-credential_failure.json", + ); + const raw = await fs.promises.readFile(expectedPath, "utf-8"); + expect(JSON.parse(raw)).toEqual(record); + }); + + test("commitErrors creates a git commit", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + await store.commitErrors([makeErrorRecord()]); + + const entries = await git.log({ fs, dir, depth: 1 }); + const entry = entries[0]; + if (!entry) throw new Error("no commit found"); + expect(entry.commit.message.trimEnd()).toBe("Record 1 error record"); + }); + + test("commitErrors rejects duplicate error records", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const record = makeErrorRecord({ seq: 1, category: "credential_failure" }); + await store.commitErrors([record]); + let thrown: Error | undefined; + try { + await store.commitErrors([record]); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Duplicate error record"); + }); + + test("commitErrors duplicate in batch leaves no orphaned files", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const good = makeErrorRecord({ seq: 1, category: "first" }); + await store.commitErrors([good]); + + // Second batch contains a duplicate of the first record and a new one. + // The duplicate should be caught in pre-flight before any writes. + const dup = makeErrorRecord({ seq: 1, category: "first" }); + const extra = makeErrorRecord({ seq: 2, category: "second" }); + let thrown: Error | undefined; + try { + await store.commitErrors([extra, dup]); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Duplicate error record"); + + // The non-duplicate record from the failed batch must not exist on disk. + const extraPath = path.join( + dir, + "state", + "errors", + "session-1", + "00000002-second.json", + ); + expect(fs.existsSync(extraPath)).toBe(false); + }); + + test("commitErrors validates sessionId path segments", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + const record = makeErrorRecord({ sessionId: "../evil" }); + let thrown: Error | undefined; + try { + await store.commitErrors([record]); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("unsafe characters"); + }); +}); + +describe("load reads from the working tree", () => { + test("load reflects the most recent writeTurns + commit cycle", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const messages: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "committed data" }], + timestamp: 1000, + }, + ]; + await store.writeTurns(messages); + await store.commit({ message: "checkpoint" }); + + const { turns: loaded } = await store.load(); + expect(loaded).toEqual(messages); + }); + + test("load throws when turns.jsonl is malformed", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + // Write garbage directly to the working tree to simulate corruption. + await fs.promises.writeFile( + path.join(dir, "turns.jsonl"), + "NOT VALID JSON", + ); + + let thrown: Error | undefined; + try { + await store.load(); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown).toBeDefined(); + }); + + test("load returns empty defaults on a fresh agent repo", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + const { turns, pendingOperations, tokenUsage, connectorState } = + await store.load(); + expect(turns).toEqual([]); + expect(pendingOperations).toEqual([]); + expect(tokenUsage).toEqual(ZERO_USAGE); + expect(connectorState).toBeNull(); + }); +}); + +describe("connector thread state", () => { + test("connector state round-trips through writeMetadata + commit/load", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const connectorState = { + threadRoot: "", + lastMessageId: "", + replyTo: "user@example.com", + cc: ["second@example.com"], + subject: "Re: Test thread", + }; + + store.setConnectorState(connectorState); + await store.writeTurns([]); + await store.writeMetadata({ + pendingOperations: [], + tokenUsage: ZERO_USAGE, + }); + await store.commit({ message: "checkpoint" }); + const loaded = await store.load(); + + expect(loaded.connectorState).toEqual(connectorState); + }); + + test("connector state with undefined subject round-trips", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const connectorState = { + threadRoot: "", + lastMessageId: "", + replyTo: "user@example.com", + cc: [], + }; + + store.setConnectorState(connectorState); + await store.writeTurns([]); + await store.writeMetadata({ + pendingOperations: [], + tokenUsage: ZERO_USAGE, + }); + await store.commit({ message: "checkpoint" }); + const loaded = await store.load(); + + expect(loaded.connectorState).toEqual(connectorState); + }); + + test("null connector state round-trips through commit/load", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([]); + await store.writeMetadata({ + pendingOperations: [], + tokenUsage: ZERO_USAGE, + }); + await store.commit({ message: "checkpoint" }); + const loaded = await store.load(); + + expect(loaded.connectorState).toBeNull(); + }); + + test("a persisted suspendedCall survives commit and reload end-to-end", async () => { + // End-to-end persistence check: write, commit, reload, compare. This + // does not by itself pin the schema declaration (arktype passes + // undeclared keys through), so store.ts carries a compile-time guard + // that keeps `PendingOperationSchema.suspendedCall` load-bearing. + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const pendingOp: PendingOperation = { + correlationId: "corr-1", + kind: "approval", + registeredAt: 0, + gateId: "gate-1", + timeoutAt: 1_000, + suspendedCall: { + id: "call-1", + name: "deploy", + arguments: { target: "prod" }, + }, + }; + + await store.writeTurns([]); + await store.writeMetadata({ + pendingOperations: [pendingOp], + tokenUsage: ZERO_USAGE, + }); + await store.commit({ message: "checkpoint" }); + const loaded = await store.load(); + + expect(loaded.pendingOperations).toEqual([pendingOp]); + }); +}); + +describe("commit signing", () => { + test("commits are signed when a signer is provided", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + const signer = async (payload: string) => + `-----BEGIN SSH SIGNATURE-----\n${base64Encode(new TextEncoder().encode(payload)).slice(0, 70)}\n-----END SSH SIGNATURE-----`; + + const store = new IsogitStore(dir, signer); + await store.writeTurns([]); + + await store.commit({ message: "signed commit" }); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (!entry) throw new Error("no commit found"); + const { commit } = await git.readCommit({ fs, dir, oid: entry.oid }); + expect(commit.gpgsig).toBeDefined(); + expect(commit.gpgsig).toContain("BEGIN SSH SIGNATURE"); + }); + + test("commits are unsigned when no signer is provided", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + const store = new IsogitStore(dir); + await store.writeTurns([]); + + await store.commit({ message: "unsigned commit" }); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (!entry) throw new Error("no commit found"); + const { commit } = await git.readCommit({ fs, dir, oid: entry.oid }); + expect(commit.gpgsig).toBeUndefined(); + }); + + test("audit commits are signed when a signer is provided", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + const signer = async (payload: string) => + `-----BEGIN SSH SIGNATURE-----\n${base64Encode(new TextEncoder().encode(payload)).slice(0, 70)}\n-----END SSH SIGNATURE-----`; + + const store = new IsogitStore(dir, signer); + await store.commitAudit([makeAuditRecord()]); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (!entry) throw new Error("no commit found"); + const { commit } = await git.readCommit({ fs, dir, oid: entry.oid }); + expect(commit.gpgsig).toBeDefined(); + expect(commit.gpgsig).toContain("BEGIN SSH SIGNATURE"); + }); + + test("error commits are signed when a signer is provided", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + + const signer = async (payload: string) => + `-----BEGIN SSH SIGNATURE-----\n${base64Encode(new TextEncoder().encode(payload)).slice(0, 70)}\n-----END SSH SIGNATURE-----`; + + const store = new IsogitStore(dir, signer); + await store.commitErrors([makeErrorRecord()]); + + const [entry] = await git.log({ fs, dir, depth: 1 }); + if (!entry) throw new Error("no commit found"); + const { commit } = await git.readCommit({ fs, dir, oid: entry.oid }); + expect(commit.gpgsig).toBeDefined(); + expect(commit.gpgsig).toContain("BEGIN SSH SIGNATURE"); + }); +}); + +function makeTransformRecord( + overrides: Partial = {}, +): TransformRecord { + return { + strategy: "size-cap", + version: "1", + parameters: { maxChars: 10000 }, + reason: "exceeded-cap", + decisions: { callId: "c1", originalBytes: 50000, kept: 10000 }, + ...overrides, + }; +} + +describe("writeBlob / readBlob", () => { + test("round-trips arbitrary bytes for a callId", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const bytes = new Uint8Array([1, 2, 3, 4, 5, 250]); + await store.writeBlob("call-abc", bytes); + const read = await store.readBlob("call-abc"); + + expect(Array.from(read)).toEqual(Array.from(bytes)); + }); + + test("text/plain content type yields .txt extension", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const bytes = new TextEncoder().encode("hello world"); + await store.writeBlob("text-call", bytes, "text/plain"); + + const expectedPath = path.join(dir, "tool-output", "text-call.txt"); + expect(fs.existsSync(expectedPath)).toBe(true); + }); + + test("application/json content type yields .json extension", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const bytes = new TextEncoder().encode('{"ok":true}'); + await store.writeBlob("json-call", bytes, "application/json"); + + const expectedPath = path.join(dir, "tool-output", "json-call.json"); + expect(fs.existsSync(expectedPath)).toBe(true); + }); + + test("unknown content type yields no extension", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const bytes = new Uint8Array([0xff, 0xee]); + await store.writeBlob("raw-call", bytes, "application/octet-stream"); + + const expectedPath = path.join(dir, "tool-output", "raw-call"); + expect(fs.existsSync(expectedPath)).toBe(true); + }); + + test("readBlob throws a clear error when the key has no blob", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + let thrown: Error | undefined; + try { + await store.readBlob("missing"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Blob not found for key"); + }); + + test("readBlob throws a clear error when tool-output/ does not exist", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + let thrown: Error | undefined; + try { + await store.readBlob("call-x"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Blob not found for key"); + }); + + test("writeBlob rejects callIds containing path traversal", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + let thrownTraversal: Error | undefined; + try { + await store.writeBlob("../escape", new Uint8Array()); + } catch (cause) { + thrownTraversal = + cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrownTraversal?.message).toContain("unsafe characters"); + + let thrownSlash: Error | undefined; + try { + await store.writeBlob("a/b", new Uint8Array()); + } catch (cause) { + thrownSlash = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrownSlash?.message).toContain("unsafe characters"); + }); + + test("writeBlob sanitizes other unsafe characters in the filename", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeBlob("call!@#xyz", new Uint8Array([7])); + + const expectedPath = path.join(dir, "tool-output", "call___xyz"); + expect(fs.existsSync(expectedPath)).toBe(true); + + // The same sanitization applies on read. + const read = await store.readBlob("call!@#xyz"); + expect(Array.from(read)).toEqual([7]); + }); + + test("writeBlob overwrites the file when the same key is written twice", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeBlob("dup", new Uint8Array([1, 2, 3])); + await store.writeBlob("dup", new Uint8Array([9, 8])); + + const read = await store.readBlob("dup"); + expect(Array.from(read)).toEqual([9, 8]); + }); +}); + +describe("writeTurns / writePrompt / writeResponse / writeManifest", () => { + test("writeTurns writes parseable JSONL to turns.jsonl", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const turns: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "hi" }], + timestamp: 1000, + }, + { + role: "assistant", + content: [{ type: "text", text: "hello" }], + model: "m", + timestamp: 2000, + }, + ]; + await store.writeTurns(turns); + + const raw = await fs.promises.readFile( + path.join(dir, "turns.jsonl"), + "utf-8", + ); + const lines = raw.split("\n").filter((l) => l.length > 0); + expect(lines.length).toBe(2); + expect(JSON.parse(lines[0] ?? "")).toEqual(turns[0]); + expect(JSON.parse(lines[1] ?? "")).toEqual(turns[1]); + }); + + test("writeTurns with an empty array produces an empty file", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([]); + + const raw = await fs.promises.readFile( + path.join(dir, "turns.jsonl"), + "utf-8", + ); + expect(raw).toBe(""); + }); + + test("writePrompt writes JSONL to prompt.jsonl", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const turns: ConversationTurn[] = [ + { + role: "system", + content: [{ type: "text", text: "be nice" }], + timestamp: 100, + }, + { + role: "user", + content: [{ type: "text", text: "do the thing" }], + timestamp: 200, + }, + ]; + await store.writePrompt(turns); + + const raw = await fs.promises.readFile( + path.join(dir, "prompt.jsonl"), + "utf-8", + ); + const lines = raw.split("\n").filter((l) => l.length > 0); + expect(lines.length).toBe(2); + expect(JSON.parse(lines[0] ?? "")).toEqual(turns[0]); + expect(JSON.parse(lines[1] ?? "")).toEqual(turns[1]); + }); + + test("writeResponse writes single-line JSONL with an AssistantTurn", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const turn: AssistantTurn = { + role: "assistant", + content: [{ type: "text", text: "done" }], + model: "test-model", + timestamp: 5000, + }; + await store.writeResponse(turn); + + const raw = await fs.promises.readFile( + path.join(dir, "response.jsonl"), + "utf-8", + ); + const lines = raw.split("\n").filter((l) => l.length > 0); + expect(lines.length).toBe(1); + expect(JSON.parse(lines[0] ?? "")).toEqual(turn); + }); + + test("writeManifest writes TransformRecord entries in order", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const records = [ + makeTransformRecord({ strategy: "first", reason: "r1" }), + makeTransformRecord({ strategy: "second", reason: "r2" }), + ]; + await store.writeManifest(records); + + const raw = await fs.promises.readFile( + path.join(dir, "manifest.jsonl"), + "utf-8", + ); + const lines = raw.split("\n").filter((l) => l.length > 0); + expect(lines.length).toBe(2); + expect(JSON.parse(lines[0] ?? "")).toEqual(records[0]); + expect(JSON.parse(lines[1] ?? "")).toEqual(records[1]); + }); +}); + +describe("commit({ message }) overload", () => { + test("commits the working-tree files written via the new writers", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const turns: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "first" }], + timestamp: 1000, + }, + ]; + await store.writeTurns(turns); + await store.writeManifest([makeTransformRecord()]); + + const result = await store.commit({ message: "cycle 1" }); + + expect(typeof result.hash).toBe("string"); + expect(result.message).toBe("cycle 1"); + + // turns.jsonl is in the commit tree. + const { blob } = await git.readBlob({ + fs, + dir, + oid: result.hash, + filepath: "turns.jsonl", + }); + const text = new TextDecoder().decode(blob); + expect(text.includes("first")).toBe(true); + }); + + test("commit writes only the per-cycle files and tool-output blobs", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([ + { + role: "user", + content: [{ type: "text", text: "x" }], + timestamp: 100, + }, + ]); + const result = await store.commit({ message: "wt only" }); + + const { tree } = await git.readTree({ fs, dir, oid: result.hash }); + const paths = tree.map((entry) => entry.path).sort(); + // Initial commit included .gitignore; the new commit stages turns.jsonl + // at the repo root. The legacy single-file serializer is gone. + expect(paths).toContain("turns.jsonl"); + expect(paths).not.toContain("state"); + }); + + test("two consecutive working-tree commits yield two distinct commits", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeTurns([ + { + role: "user", + content: [{ type: "text", text: "v1" }], + timestamp: 100, + }, + ]); + const c1 = await store.commit({ message: "cycle one" }); + + await store.writeTurns([ + { + role: "user", + content: [{ type: "text", text: "v1" }], + timestamp: 100, + }, + { + role: "assistant", + content: [{ type: "text", text: "v2" }], + model: "m", + timestamp: 200, + }, + ]); + const c2 = await store.commit({ message: "cycle two" }); + + expect(c1.hash).not.toBe(c2.hash); + expect(c2.parentHash).toBe(c1.hash); + + const entries = await store.log(5); + const messages = entries.map((e) => e.message); + expect(messages).toContain("cycle one"); + expect(messages).toContain("cycle two"); + + // The latest turns.jsonl reflects the second write. + const { blob } = await git.readBlob({ + fs, + dir, + oid: c2.hash, + filepath: "turns.jsonl", + }); + const text = new TextDecoder().decode(blob); + expect(text.includes("v2")).toBe(true); + }); + + test("blobs written via writeBlob land in the commit tree", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeBlob( + "spill-1", + new TextEncoder().encode("big payload"), + "text/plain", + ); + const result = await store.commit({ message: "with spill" }); + + const { blob } = await git.readBlob({ + fs, + dir, + oid: result.hash, + filepath: "tool-output/spill-1.txt", + }); + expect(new TextDecoder().decode(blob)).toBe("big payload"); + }); + + test("initAgentRepo does not create the legacy state context file", async () => { + const dir = await tempDir(); + await createIsogitStore(dir); + + // The legacy serializer wrote a single state file under `state/`. The + // working-tree layout replaces it with per-cycle files at the repo root. + const legacyName = ["context", "json"].join("."); + const exists = await fs.promises + .access(path.join(dir, "state", legacyName)) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); +}); + +describe("readManifestHistory", () => { + test("returns records from the most recent commits, newest first", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + const r1 = makeTransformRecord({ strategy: "first" }); + await store.writeManifest([r1]); + await store.commit({ message: "c1" }); + + const r2 = makeTransformRecord({ strategy: "second" }); + await store.writeManifest([r2]); + await store.commit({ message: "c2" }); + + const r3a = makeTransformRecord({ strategy: "third-a" }); + const r3b = makeTransformRecord({ strategy: "third-b" }); + await store.writeManifest([r3a, r3b]); + await store.commit({ message: "c3" }); + + const history = await store.readManifestHistory(5); + + // Newest commit first; within a commit, natural file order. + expect(history.map((r) => r.strategy)).toEqual([ + "third-a", + "third-b", + "second", + "first", + ]); + }); + + test("respects the limit parameter", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + for (let i = 0; i < 3; i++) { + await store.writeManifest([ + makeTransformRecord({ strategy: `cycle-${String(i)}` }), + ]); + await store.commit({ message: `c${String(i)}` }); + } + + const limited = await store.readManifestHistory(2); + expect(limited.map((r) => r.strategy)).toEqual(["cycle-2", "cycle-1"]); + }); + + test("returns empty array when limit is zero", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + await store.writeManifest([makeTransformRecord()]); + await store.commit({ message: "c" }); + + const history = await store.readManifestHistory(0); + expect(history).toEqual([]); + }); + + test("skips commits that lack manifest.jsonl", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + // The initial commit from initAgentRepo and the legacy commit below both + // have no manifest.jsonl, so they should be skipped. + await store.writeTurns([]); + + await store.commit({ message: "legacy" }); + + const r = makeTransformRecord(); + await store.writeManifest([r]); + await store.commit({ message: "with manifest" }); + + const history = await store.readManifestHistory(10); + expect(history).toEqual([r]); + }); + + test("rejects a manifest with an invalid record", async () => { + const dir = await tempDir(); + const store = await createIsogitStore(dir); + + // Hand-write a corrupt manifest.jsonl and commit it via the working-tree + // overload so it lands in git without going through writeManifest. + await fs.promises.writeFile( + path.join(dir, "manifest.jsonl"), + JSON.stringify({ strategy: "bogus" }) + "\n", + ); + await store.commit({ message: "corrupt" }); + + let thrown: Error | undefined; + try { + await store.readManifestHistory(5); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toMatch(/Invalid manifest record/); + }); +}); diff --git a/vendor/intx-storage-isogit/src/store.ts b/vendor/intx-storage-isogit/src/store.ts new file mode 100644 index 000000000..38aaa8e9b --- /dev/null +++ b/vendor/intx-storage-isogit/src/store.ts @@ -0,0 +1,766 @@ +import fs from "node:fs"; +import path from "node:path"; +import git from "isomorphic-git"; +import { + ApprovalSnapshot, + ContentBlock, + TokenUsage, + ToolCall, + TransformRecord, + type AssistantTurn, + type TransformRecord as TransformRecordType, + type ContextStore, + type AuditStore, + type ContextCommit, + type ConversationTurn, + type ConnectorThreadState, + type PendingOperation, +} from "@intx/types/runtime"; +import { type } from "arktype"; +import { + AuditRecord, + type AuditRecord as AuditRecordType, + type ErrorRecord, +} from "@intx/types/audit"; +import { AUTHOR } from "./init"; +import type { CommitSigner } from "./signer"; +import { buildSigningArgs } from "./commit-helpers"; +import { withRepoDirLock } from "./repo-lock"; +import { maybeGCUnderLock, type GCPolicy } from "./gc"; + +const TURNS_FILE = "turns.jsonl"; +const PROMPT_FILE = "prompt.jsonl"; +const RESPONSE_FILE = "response.jsonl"; +const MANIFEST_FILE = "manifest.jsonl"; +const METADATA_FILE = "metadata.json"; +const TOOL_OUTPUT_DIR = "tool-output"; + +const BLOB_EXTENSIONS: Readonly> = { + "text/plain": ".txt", + "application/json": ".json", +}; + +function blobExtensionFor(contentType: string | undefined): string { + if (contentType === undefined) return ""; + const ext = BLOB_EXTENSIONS[contentType]; + return ext ?? ""; +} + +const ConnectorThreadStateSchema = type({ + threadRoot: "string", + lastMessageId: "string", + replyTo: "string", + cc: "string[]", + "subject?": "string", +}); + +const ConversationTurnSchema = type({ + role: "'user' | 'assistant' | 'system'", + content: ContentBlock.array(), + "model?": "string", + timestamp: "number", +}); + +const PendingOperationSchema = type({ + correlationId: "string", + kind: "'approval'", + "expectedFrom?": "string", + registeredAt: "number", + gateId: "string", + "timeoutAt?": "number", + "suspendedCall?": ToolCall, + "approvalSnapshot?": ApprovalSnapshot, +}); + +// The persisted schema and the in-memory PendingOperation type are two +// separate declarations kept in lockstep. arktype passes undeclared keys +// through at runtime, so dropping `suspendedCall?` from the schema would not +// surface as a runtime failure. Projecting the field off the schema's +// inferred type makes the declaration load-bearing: the indexed access +// errors under `tsc` if the schema stops carrying the field, and the return +// annotation pins its persisted type to `ToolCall`. +const _persistedSuspendedCall = ( + op: typeof PendingOperationSchema.infer, +): ToolCall | undefined => op.suspendedCall; +void _persistedSuspendedCall; + +// Same lockstep guard for the approval snapshot: the persisted schema must +// keep carrying `approvalSnapshot` so a rehydrated pending operation still +// exposes it. The projection errors under `tsc` if the schema drops the field. +const _persistedApprovalSnapshot = ( + op: typeof PendingOperationSchema.infer, +): ApprovalSnapshot | undefined => op.approvalSnapshot; +void _persistedApprovalSnapshot; + +const MetadataSchema = type({ + pendingOperations: PendingOperationSchema.array(), + tokenUsage: TokenUsage, + connectorState: type("null").or(ConnectorThreadStateSchema), +}); + +type MetadataData = { + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + connectorState: ConnectorThreadState | null; +}; + +const EMPTY_USAGE: TokenUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, +}; + +function parseMetadata(raw: unknown): MetadataData { + const result = MetadataSchema(raw); + if (result instanceof type.errors) { + throw new Error( + `metadata.json has unexpected structure: ${result.summary}`, + ); + } + return { + pendingOperations: result.pendingOperations, + tokenUsage: result.tokenUsage, + connectorState: result.connectorState, + }; +} + +/** + * Walk the first-parent commit chain from HEAD, newest-first, stopping at + * `limit` entries or at the first parent that is not present on disk. + * + * `git.log` throws `NotFoundError` the moment it reaches a missing commit, + * which is the steady state under `tip-only` GC: the collector prunes + * ancestry, leaving the tip's older parents absent. The durable conversation + * lives in the working-tree files at the tip, not in this history, so the + * commit log is a best-effort time-travel surface — degrade to the surviving + * slice rather than throwing into a caller (e.g. the agent's `checkpoints` + * tool). Any non-absence read error still surfaces. + */ +async function tolerantLog( + dir: string, + limit: number, +): Promise>[]> { + const out: Awaited>[] = []; + let oid: string | undefined; + try { + oid = await git.resolveRef({ fs, dir, ref: "HEAD" }); + } catch { + return out; + } + while (oid !== undefined && out.length < limit) { + let entry: Awaited>; + try { + entry = await git.readCommit({ fs, dir, oid }); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "NotFoundError") + break; + throw err; + } + out.push(entry); + oid = entry.commit.parent[0]; + } + return out; +} + +async function readCommitLog( + dir: string, + limit: number, +): Promise { + const entries = await tolerantLog(dir, limit); + return entries.map((e) => { + const base = { + hash: e.oid, + message: e.commit.message.trimEnd(), + timestamp: e.commit.author.timestamp * 1000, + }; + const parent = e.commit.parent[0]; + return parent !== undefined ? { ...base, parentHash: parent } : base; + }); +} + +const AUDIT_DIR = "state/audit"; +const ERRORS_DIR = "state/errors"; + +const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/; +const UNSAFE_FILENAME_CHARS = /[^a-zA-Z0-9_-]/g; + +function assertSafeSegment(value: string, label: string): void { + if (!SAFE_PATH_SEGMENT.test(value)) { + throw new Error( + `${label} contains unsafe characters: ${JSON.stringify(value)}`, + ); + } +} + +/** + * Validate a callId for use in a filesystem path and return the sanitized + * form used as the filename. Rejects path traversal (`..`, `/`) outright; + * other unsafe characters are replaced with `_`. + */ +function sanitizeCallId(callId: string): string { + if (callId.includes("..") || callId.includes("/")) { + throw new Error( + `callId contains unsafe characters: ${JSON.stringify(callId)}`, + ); + } + return callId.replace(UNSAFE_FILENAME_CHARS, "_"); +} + +async function pathExists(fullPath: string): Promise { + try { + await fs.promises.access(fullPath); + return true; + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { + return false; + } + throw cause; + } +} + +function encodeJsonlLines(records: readonly unknown[]): string { + if (records.length === 0) return ""; + return records.map((r) => JSON.stringify(r)).join("\n") + "\n"; +} + +function decodeJsonlLines(text: string): unknown[] { + if (text.length === 0) return []; + const lines = text.split("\n"); + if (lines[lines.length - 1] === "") lines.pop(); + return lines.map((line) => JSON.parse(line) as unknown); +} + +async function readBlobAtCommit( + dir: string, + oid: string, + filepath: string, +): Promise { + try { + const { blob } = await git.readBlob({ fs, dir, oid, filepath }); + return blob; + } catch (cause) { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "NotFoundError" || cause.code === "ENOENT") + ) { + return null; + } + return null; + } +} + +function parseTurns(text: string): ConversationTurn[] { + const lines = decodeJsonlLines(text); + const turns: ConversationTurn[] = []; + for (const raw of lines) { + const result = ConversationTurnSchema(raw); + if (result instanceof type.errors) { + throw new Error( + `turns.jsonl has unexpected structure: ${result.summary}`, + ); + } + turns.push(result); + } + return turns; +} + +/** + * isomorphic-git-backed implementation of ContextStore and AuditStore. + * + * Conversation state lives in `turns.jsonl`; per-cycle prompt/response/manifest + * data lives in `prompt.jsonl`, `response.jsonl`, and `manifest.jsonl`. Pending + * operations, token usage, and connector state are serialized into + * `metadata.json`. Audit records are written as individual JSON files under + * `state/audit/{sessionId}/`. All files are tracked by the git repository at + * `dir`. The caller is responsible for calling `initAgentRepo(dir)` before + * constructing. + */ +/** + * Extra reads the durable WAL mirror needs beyond `ContextStore`, kept + * off the shared interface because they are isogit-specific. + */ +export interface DurableMirrorReads { + /** + * The turns most recently handed to `writeTurns`, by reference -- the + * reactor's live array, not a copy. Lets the WAL mirror slice the new + * turns from memory instead of re-reading and re-parsing `turns.jsonl` + * every boundary. Safe because the local store is single-writer and + * in-process: nothing else writes `turns.jsonl`, so the last-written + * array equals the on-disk state at the mirror boundary. + */ + peekTurns(): ConversationTurn[]; + /** + * Read only `metadata.json` (pending operations, token usage, connector + * state), skipping the O(N) turns parse `load` pays. The mirror gets + * its turns from `peekTurns`. + */ + loadMetadata(): Promise<{ + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + connectorState: ConnectorThreadState | null; + }>; +} + +export class IsogitStore + implements ContextStore, AuditStore, DurableMirrorReads +{ + private readonly dir: string; + private readonly signer: CommitSigner | undefined; + private readonly gcPolicy: GCPolicy | undefined; + private pendingConnectorState: ConnectorThreadState | null = null; + private lastTurns: ConversationTurn[] = []; + + constructor(dir: string, signer?: CommitSigner, gcPolicy?: GCPolicy) { + this.dir = dir; + this.signer = signer; + this.gcPolicy = gcPolicy; + } + + private signingArgs() { + return buildSigningArgs(this.signer); + } + + // Reclaim after a write while the per-directory lock is still held, per + // the configured policy. A no-op when no policy was supplied. + private async maybeGC(): Promise { + if (this.gcPolicy === undefined) return; + await maybeGCUnderLock(this.dir, this.gcPolicy); + } + + setConnectorState(state: ConnectorThreadState | null): void { + this.pendingConnectorState = state; + } + + async load(_signal?: AbortSignal): Promise<{ + turns: ConversationTurn[]; + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + connectorState: ConnectorThreadState | null; + }> { + const turnsPath = path.join(this.dir, TURNS_FILE); + + let turns: ConversationTurn[] = []; + if (await pathExists(turnsPath)) { + const text = await fs.promises.readFile(turnsPath, "utf-8"); + turns = parseTurns(text); + } + + const metadata = await this.loadMetadata(); + return { turns, ...metadata }; + } + + async loadMetadata(): Promise<{ + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + connectorState: ConnectorThreadState | null; + }> { + const metadataPath = path.join(this.dir, METADATA_FILE); + if (!(await pathExists(metadataPath))) { + return { + pendingOperations: [], + tokenUsage: { ...EMPTY_USAGE }, + connectorState: null, + }; + } + const text = await fs.promises.readFile(metadataPath, "utf-8"); + const parsed: unknown = JSON.parse(text); + const data = parseMetadata(parsed); + return { + pendingOperations: data.pendingOperations, + tokenUsage: data.tokenUsage, + connectorState: data.connectorState, + }; + } + + async commit( + options: { message: string }, + _signal?: AbortSignal, + ): Promise { + return withRepoDirLock(this.dir, async () => { + const tracked = [ + TURNS_FILE, + PROMPT_FILE, + RESPONSE_FILE, + MANIFEST_FILE, + METADATA_FILE, + ]; + for (const filepath of tracked) { + const fullPath = path.join(this.dir, filepath); + if (await pathExists(fullPath)) { + await git.add({ fs, dir: this.dir, filepath }); + } + } + + const blobsDir = path.join(this.dir, TOOL_OUTPUT_DIR); + if (await pathExists(blobsDir)) { + const entries = await fs.promises.readdir(blobsDir); + for (const entry of entries) { + await git.add({ + fs, + dir: this.dir, + filepath: `${TOOL_OUTPUT_DIR}/${entry}`, + }); + } + } + + const oid = await git.commit({ + fs, + dir: this.dir, + message: options.message, + author: AUTHOR, + ...this.signingArgs(), + }); + + const described = await this.describeHead(oid, options.message); + await this.maybeGC(); + return described; + }); + } + + private async describeHead( + expectedOid: string, + message: string, + ): Promise { + const entries = await git.log({ fs, dir: this.dir, depth: 2 }); + const entry = entries[0]; + if (entry === undefined || entry.oid !== expectedOid) { + throw new Error( + `Unexpected log state after commit: expected ${expectedOid} as HEAD`, + ); + } + const parentOid = entries[1]?.oid; + const base = { + hash: expectedOid, + message: message.trimEnd(), + timestamp: entry.commit.author.timestamp * 1000, + }; + return parentOid !== undefined ? { ...base, parentHash: parentOid } : base; + } + + async branch(name: string, _signal?: AbortSignal): Promise { + await git.branch({ fs, dir: this.dir, ref: name }); + } + + async log(limit?: number, _signal?: AbortSignal): Promise { + return readCommitLog(this.dir, limit ?? 10); + } + + async readAt( + hash: string, + _signal?: AbortSignal, + ): Promise { + const blob = await readBlobAtCommit(this.dir, hash, TURNS_FILE); + if (blob === null) return []; + const text = new TextDecoder().decode(blob); + return parseTurns(text); + } + + async writeBlob( + key: string, + bytes: Uint8Array, + contentType?: string, + _signal?: AbortSignal, + ): Promise { + const safeKey = sanitizeCallId(key); + const filename = `${safeKey}${blobExtensionFor(contentType)}`; + const dirPath = path.join(this.dir, TOOL_OUTPUT_DIR); + await fs.promises.mkdir(dirPath, { recursive: true }); + await fs.promises.writeFile(path.join(dirPath, filename), bytes); + } + + async readBlob(key: string, _signal?: AbortSignal): Promise { + const safeKey = sanitizeCallId(key); + const dirPath = path.join(this.dir, TOOL_OUTPUT_DIR); + let entries: string[]; + try { + entries = await fs.promises.readdir(dirPath); + } catch (cause) { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) { + throw new Error(`Blob not found for key: ${JSON.stringify(key)}`); + } + throw cause; + } + + const match = entries.find( + (entry) => entry === safeKey || entry.startsWith(`${safeKey}.`), + ); + if (match === undefined) { + throw new Error(`Blob not found for key: ${JSON.stringify(key)}`); + } + const buf = await fs.promises.readFile(path.join(dirPath, match)); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + async writePrompt( + turns: ConversationTurn[], + _signal?: AbortSignal, + ): Promise { + await fs.promises.writeFile( + path.join(this.dir, PROMPT_FILE), + encodeJsonlLines(turns), + ); + } + + async writeResponse( + turn: AssistantTurn, + _signal?: AbortSignal, + ): Promise { + await fs.promises.writeFile( + path.join(this.dir, RESPONSE_FILE), + encodeJsonlLines([turn]), + ); + } + + async writeManifest( + records: TransformRecordType[], + _signal?: AbortSignal, + ): Promise { + await fs.promises.writeFile( + path.join(this.dir, MANIFEST_FILE), + encodeJsonlLines(records), + ); + } + + async writeTurns( + turns: ConversationTurn[], + _signal?: AbortSignal, + ): Promise { + await fs.promises.writeFile( + path.join(this.dir, TURNS_FILE), + encodeJsonlLines(turns), + ); + // Advance the in-memory marker only after the durable write succeeds. + // peekTurns must never surface an array that failed to persist -- a + // write failure leaves it pointing at the last array that did. + this.lastTurns = turns; + } + + peekTurns(): ConversationTurn[] { + return this.lastTurns; + } + + /** + * Write `metadata.json` containing pending operations, token usage, and the + * currently-buffered connector state. The reactor calls this once per cycle + * before issuing the working-tree commit so the file is staged atomically + * with the per-cycle conversation data. + */ + async writeMetadata( + metadata: { + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + }, + _signal?: AbortSignal, + ): Promise { + const payload: MetadataData = { + pendingOperations: metadata.pendingOperations, + tokenUsage: metadata.tokenUsage, + connectorState: this.pendingConnectorState, + }; + await fs.promises.writeFile( + path.join(this.dir, METADATA_FILE), + JSON.stringify(payload, null, 2), + ); + } + + async readManifestHistory( + limit: number, + _signal?: AbortSignal, + ): Promise { + if (limit <= 0) return []; + const entries = await tolerantLog(this.dir, limit); + const collected: TransformRecordType[] = []; + for (const entry of entries) { + let blob: Uint8Array; + try { + ({ blob } = await git.readBlob({ + fs, + dir: this.dir, + oid: entry.oid, + filepath: MANIFEST_FILE, + })); + } catch { + continue; + } + const text = new TextDecoder().decode(blob); + const parsedLines = decodeJsonlLines(text); + for (const raw of parsedLines) { + const result = TransformRecord(raw); + if (result instanceof type.errors) { + throw new Error( + `Invalid manifest record at commit ${entry.oid}: ${result.summary}`, + ); + } + collected.push(result); + } + } + return collected; + } + + async commitAudit( + records: AuditRecordType[], + _signal?: AbortSignal, + ): Promise { + if (records.length === 0) return; + await withRepoDirLock(this.dir, async () => { + // Pre-flight: validate all records and check for duplicates before + // writing anything to disk. This avoids orphaned files if a + // duplicate is detected partway through the batch. + const planned: { record: AuditRecordType; filepath: string }[] = []; + for (const record of records) { + assertSafeSegment(record.sessionId, "sessionId"); + const safeCallId = sanitizeCallId(record.callId); + + const filepath = path.join( + AUDIT_DIR, + record.sessionId, + `${safeCallId}.json`, + ); + const fullPath = path.join(this.dir, filepath); + + try { + await fs.promises.access(fullPath); + throw new Error( + `Duplicate audit record: ${record.sessionId}/${record.callId}`, + ); + } catch (e) { + if (e instanceof Error && "code" in e && e.code === "ENOENT") { + // Expected: file does not exist yet. + } else { + throw e; + } + } + + planned.push({ record, filepath }); + } + + // Write phase: all validation passed, safe to write files. + for (const { record, filepath } of planned) { + const sessionDir = path.join(this.dir, AUDIT_DIR, record.sessionId); + await fs.promises.mkdir(sessionDir, { recursive: true }); + const fullPath = path.join(this.dir, filepath); + await fs.promises.writeFile(fullPath, JSON.stringify(record, null, 2)); + await git.add({ fs, dir: this.dir, filepath }); + } + + const count = records.length; + const noun = count === 1 ? "record" : "records"; + await git.commit({ + fs, + dir: this.dir, + message: `Record ${count} tool audit ${noun}`, + author: AUTHOR, + ...this.signingArgs(), + }); + await this.maybeGC(); + }); + } + + async commitErrors( + records: ErrorRecord[], + _signal?: AbortSignal, + ): Promise { + if (records.length === 0) return; + await withRepoDirLock(this.dir, async () => { + // Pre-flight: validate all records and check for duplicates before + // writing anything to disk. This avoids orphaned files if a + // duplicate is detected partway through the batch. + const planned: { record: ErrorRecord; filepath: string }[] = []; + for (const record of records) { + assertSafeSegment(record.sessionId, "sessionId"); + + const sanitizedCategory = record.category.replace( + /[^a-zA-Z0-9_-]/g, + "_", + ); + const seq = String(record.seq).padStart(8, "0"); + const filepath = path.join( + ERRORS_DIR, + record.sessionId, + `${seq}-${sanitizedCategory}.json`, + ); + const fullPath = path.join(this.dir, filepath); + + try { + await fs.promises.access(fullPath); + throw new Error( + `Duplicate error record: ${record.sessionId}/${seq}-${sanitizedCategory}`, + ); + } catch (e) { + if (e instanceof Error && "code" in e && e.code === "ENOENT") { + // Expected: file does not exist yet. + } else { + throw e; + } + } + + planned.push({ record, filepath }); + } + + // Write phase: all validation passed, safe to write files. + for (const { record, filepath } of planned) { + const sessionDir = path.join(this.dir, ERRORS_DIR, record.sessionId); + await fs.promises.mkdir(sessionDir, { recursive: true }); + const fullPath = path.join(this.dir, filepath); + await fs.promises.writeFile(fullPath, JSON.stringify(record, null, 2)); + await git.add({ fs, dir: this.dir, filepath }); + } + + const count = records.length; + const noun = count === 1 ? "record" : "records"; + await git.commit({ + fs, + dir: this.dir, + message: `Record ${count} error ${noun}`, + author: AUTHOR, + ...this.signingArgs(), + }); + await this.maybeGC(); + }); + } + + async loadAudit( + sessionId: string, + _signal?: AbortSignal, + ): Promise { + assertSafeSegment(sessionId, "sessionId"); + const sessionDir = path.join(this.dir, AUDIT_DIR, sessionId); + + let entries: string[]; + try { + entries = await fs.promises.readdir(sessionDir); + } catch (cause) { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) { + return []; + } + throw cause; + } + + const records: AuditRecordType[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const fullPath = path.join(sessionDir, entry); + const raw = await fs.promises.readFile(fullPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + const result = AuditRecord(parsed); + if (result instanceof type.errors) { + throw new Error(`Invalid audit record in ${entry}: ${result.summary}`); + } + records.push(result); + } + + records.sort((a, b) => a.seq - b.seq); + return records; + } +} diff --git a/vendor/intx-types/LICENSE b/vendor/intx-types/LICENSE new file mode 100644 index 000000000..c6487f4fd --- /dev/null +++ b/vendor/intx-types/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/vendor/intx-types/README.md b/vendor/intx-types/README.md new file mode 100644 index 000000000..70a144fa8 --- /dev/null +++ b/vendor/intx-types/README.md @@ -0,0 +1,49 @@ +# @intx/types + +Foundational types for the Interchange monorepo. ArkType runtime +validators, API contract types, runtime interfaces, and sidecar +wire frames. Nearly every other package imports from here, which +makes this the canonical home for any shape that crosses a package +boundary. + +Each entry point pairs an ArkType validator with its inferred +TypeScript type so consumers can validate at the boundary and +trust the resulting value internally. + +## Surface + +The package is split into several entry points so consumers only +pull in the shapes they need: + +- `@intx/types` — domain validators and shared primitives: tenants, + principals, roles, grants, agents, sessions, approvals, wallets, + providers, oauth clients, credentials, offerings, the model catalog + (models, model providers, offerings, and append-only pricing, plus + the model-requirement and invoker-preference shapes and the model + discovery view), observability, sidecar status enums (distinct from + the wire frames under `@intx/types/sidecar` below), agent addresses, + hex and base64 helpers, and the `hasCode` error guard. +- `@intx/types/authz` — grant rules, condition contexts, and + authorization result shapes shared between `@intx/authz` and the + hub. +- `@intx/types/audit` — the `AuditAuthz`, `AuditRecord`, and + `ErrorRecord` shapes for tool-authorization and error records. +- `@intx/types/runtime` — inference and harness contracts: + `ContextStore`, `ToolRunner`, `ToolDefinition`, `AuditStore`, + `InferenceSource` (the resolved provider/model/credential a call + executes against), retry policy, director and reactor types. +- `@intx/types/runtime-capabilities` — the capability-registry + contract harness extensions resolve against (e.g. mail transport, + blob reader). +- `@intx/types/sidecar` — hub-sidecar WebSocket wire frames. +- `@intx/types/grant-wire` — grant-update wire frames pushed from + the hub to the sidecar. +- `@intx/types/tool-packages` — schemas for the tool-package + distribution path: pin shapes (`ToolPackagePin`, + `ToolPackagePinArray`, `ToolPackagePinName`), source variants + (`ToolPackageAssetSource`, `ToolPackageRegistrySource`, + `ToolPackageSource`), and the deploy-pack manifest + (`ToolPackageManifestEntry`, `ToolPackageManifest`). +- `@intx/types/package-json` — the `PackageJSON` validator for the + subset of `package.json` fields the asset substrate and tool-package + builders read, including the `interchange.tools` extension. diff --git a/vendor/intx-types/package.json b/vendor/intx-types/package.json new file mode 100644 index 000000000..90002f986 --- /dev/null +++ b/vendor/intx-types/package.json @@ -0,0 +1,55 @@ +{ + "name": "@intx/types", + "version": "0.2.2", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./authz": { + "types": "./src/authz.ts", + "default": "./src/authz.ts" + }, + "./audit": { + "types": "./src/audit.ts", + "default": "./src/audit.ts" + }, + "./content-type": { + "types": "./src/content-type.ts", + "default": "./src/content-type.ts" + }, + "./runtime": { + "types": "./src/runtime.ts", + "default": "./src/runtime.ts" + }, + "./runtime-capabilities": { + "types": "./src/runtime-capabilities.ts", + "default": "./src/runtime-capabilities.ts" + }, + "./sidecar": { + "types": "./src/sidecar.ts", + "default": "./src/sidecar.ts" + }, + "./grant-wire": { + "types": "./src/grant-wire.ts", + "default": "./src/grant-wire.ts" + }, + "./tool-packages": { + "types": "./src/tool-packages.ts", + "default": "./src/tool-packages.ts" + }, + "./package-json": { + "types": "./src/package-json.ts", + "default": "./src/package-json.ts" + } + }, + "dependencies": { + "arktype": "catalog:", + "semver": "catalog:" + }, + "devDependencies": { + "@types/semver": "catalog:" + } +} diff --git a/vendor/intx-types/src/agent-address.test.ts b/vendor/intx-types/src/agent-address.test.ts new file mode 100644 index 000000000..b52f6451c --- /dev/null +++ b/vendor/intx-types/src/agent-address.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect } from "bun:test"; + +import { + formatAgentAddress, + isAgentAddress, + parseAgentAddress, +} from "./agent-address"; + +describe("formatAgentAddress", () => { + test("joins instanceId and domain with @", () => { + expect(formatAgentAddress("ins_abc123", "tenant.example")).toBe( + "ins_abc123@tenant.example", + ); + }); +}); + +describe("parseAgentAddress", () => { + test("splits a well-formed address", () => { + expect(parseAgentAddress("ins_abc123@tenant.example")).toEqual({ + instanceId: "ins_abc123", + domain: "tenant.example", + }); + }); + + test("returns null when instance prefix is missing", () => { + expect(parseAgentAddress("usr_alice@tenant.example")).toBeNull(); + }); + + test("returns null when the @ is missing", () => { + expect(parseAgentAddress("ins_abc123")).toBeNull(); + }); + + test("returns null when the local part is empty", () => { + expect(parseAgentAddress("@tenant.example")).toBeNull(); + }); + + test("returns null when the domain part is empty", () => { + expect(parseAgentAddress("ins_abc123@")).toBeNull(); + }); + + test("does not validate the shape of the domain", () => { + expect(parseAgentAddress("ins_abc123@not a real domain")).toEqual({ + instanceId: "ins_abc123", + domain: "not a real domain", + }); + }); + + test("splits on the first @ and treats the rest as the domain", () => { + expect(parseAgentAddress("ins_abc123@foo@bar")).toEqual({ + instanceId: "ins_abc123", + domain: "foo@bar", + }); + }); +}); + +describe("isAgentAddress", () => { + test("true for ins_-prefixed addresses with a domain", () => { + expect(isAgentAddress("ins_abc123@tenant.example")).toBe(true); + }); + + test("false for non-agent local parts", () => { + expect(isAgentAddress("usr_alice@tenant.example")).toBe(false); + }); + + test("false for bare instance IDs without a domain", () => { + expect(isAgentAddress("ins_abc123")).toBe(false); + }); +}); diff --git a/vendor/intx-types/src/agent-address.ts b/vendor/intx-types/src/agent-address.ts new file mode 100644 index 000000000..8327fc578 --- /dev/null +++ b/vendor/intx-types/src/agent-address.ts @@ -0,0 +1,34 @@ +// Agent instance addresses are "@" where instanceId is +// the `ins_`-prefixed identifier produced by generateId("instance"). These +// helpers are the single source of truth for that format. +// +// The shape of the right-hand side of the "@" is not validated beyond the +// requirement that it be non-empty: tightening the contract (DNS-ish +// validation, normalisation, etc.) is a separate follow-up. +// +// `@intx/hub-sessions`'s `parseAgentId` is the canonical throwing wrapper +// over `parseAgentAddress` — call it when a `null` return would +// propagate as a silent bug, and keep this parser's `null` return +// reserved for callers that already have a structured fallback. + +const INSTANCE_PREFIX = "ins_"; + +export function formatAgentAddress(instanceId: string, domain: string): string { + return `${instanceId}@${domain}`; +} + +export function parseAgentAddress( + address: string, +): { instanceId: string; domain: string } | null { + const atIdx = address.indexOf("@"); + if (atIdx <= 0) return null; + const instanceId = address.slice(0, atIdx); + const domain = address.slice(atIdx + 1); + if (!instanceId.startsWith(INSTANCE_PREFIX)) return null; + if (domain.length === 0) return null; + return { instanceId, domain }; +} + +export function isAgentAddress(address: string): boolean { + return parseAgentAddress(address) !== null; +} diff --git a/vendor/intx-types/src/agent-data.ts b/vendor/intx-types/src/agent-data.ts new file mode 100644 index 000000000..778811977 --- /dev/null +++ b/vendor/intx-types/src/agent-data.ts @@ -0,0 +1,43 @@ +import { type } from "arktype"; + +export const FileEntry = type({ + path: "string", + type: "'file' | 'directory'", + "size?": "number | null", + "modifiedAt?": "string | null", +}); + +export const FileContent = type({ + path: "string", + content: "string", + "encoding?": "'utf-8' | 'base64'", +}); + +export const HistoryEntry = type({ + ref: "string", + message: "string", + author: "string", + timestamp: "string", + "filesChanged?": "number", +}); + +export const CommitDetail = type({ + ref: "string", + message: "string", + author: "string", + timestamp: "string", + changes: type({ + path: "string", + status: "'added' | 'modified' | 'deleted'", + "additions?": "number", + "deletions?": "number", + }).array(), +}); + +export const BranchInfo = type({ + name: "string", + "isCurrent?": "boolean", + "lastCommitRef?": "string | null", + "lastCommitMessage?": "string | null", + "lastCommitAt?": "string | null", +}); diff --git a/vendor/intx-types/src/approvals.ts b/vendor/intx-types/src/approvals.ts new file mode 100644 index 000000000..42725c4e1 --- /dev/null +++ b/vendor/intx-types/src/approvals.ts @@ -0,0 +1,34 @@ +import { type } from "arktype"; + +export const ApprovalResponse = type({ + id: "string", + tenantId: "string", + deploymentId: type("string").describe( + "The workflow deployment the approval originates from. Every approval is raised during a workflow run; there is no launched single agent or agent-definition row behind it.", + ), + runId: "string", + agentAddress: "string", + correlationId: type("string").describe( + "Ties the approval to the suspension it resolves. The parked run awaits the control signal keyed by this id.", + ), + toolDefinition: type("Record").describe( + "The approver-facing tool snapshot (name, description, input schema) captured at suspend time.", + ), + toolArguments: "Record", + scope: "'once' | 'always' | null", + status: "'pending' | 'approved' | 'rejected' | 'timeout' | 'expired'", + timeoutAt: type("string | null").describe( + "Deadline after which the approval expires. Null records a hold-indefinitely approval with no deadline.", + ), + resolvedAt: "string | null", + createdAt: "string", + updatedAt: "string", +}); + +export const ApproveAction = type({ + scope: "'once' | 'always'", +}); + +export const RejectAction = type({ + "message?": "string", +}); diff --git a/vendor/intx-types/src/assets.ts b/vendor/intx-types/src/assets.ts new file mode 100644 index 000000000..9f713cfd8 --- /dev/null +++ b/vendor/intx-types/src/assets.ts @@ -0,0 +1,42 @@ +import { type } from "arktype"; + +const assetKindDescription = + "Category of the asset, used together with `name` to address it. The (kind, name) pair is what callers resolve against, and it is unique within a tenant."; + +export const AssetResponse = type({ + id: "string", + tenantId: "string", + kind: type("string").describe(assetKindDescription), + name: "string", + displayName: "string | null", + creatorPrincipalId: "string | null", + createdAt: "string", + updatedAt: "string", +}); + +/** + * `AssetResponse` extended with the tenant that supplied the row. The + * inherited-list endpoint stamps every row with this tag so callers can + * distinguish locally-defined assets from inherited ones without + * issuing a second round-trip per row. + */ +export const AssetWithOriginResponse = type({ + id: "string", + tenantId: "string", + kind: type("string").describe(assetKindDescription), + name: "string", + displayName: "string | null", + creatorPrincipalId: "string | null", + createdAt: "string", + updatedAt: "string", + origin: type({ + tenantId: type("string").describe( + "The tenant that supplied this row -- either the queried tenant itself or an ancestor it inherits from.", + ), + direct: type("boolean").describe( + "True when the asset is declared on the queried tenant itself; false when it is inherited from an ancestor tenant.", + ), + }).describe( + "Which tenant in the hierarchy this asset row came from, distinguishing locally-defined assets from inherited ones.", + ), +}); diff --git a/vendor/intx-types/src/attachments.test.ts b/vendor/intx-types/src/attachments.test.ts new file mode 100644 index 000000000..d33329a21 --- /dev/null +++ b/vendor/intx-types/src/attachments.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from "bun:test"; +import { + ATTACHMENT_ALLOWLIST, + attachmentCategory, + isAllowedMimeType, + PER_ATTACHMENT_LIMIT_BYTES, + PER_MESSAGE_TOTAL_LIMIT_BYTES, +} from "./attachments"; + +describe("attachment allowlist", () => { + test("every allowlisted mimeType is accepted by the helper", () => { + for (const mimeType of Object.keys(ATTACHMENT_ALLOWLIST)) { + expect(isAllowedMimeType(mimeType)).toBe(true); + } + }); + + test("unknown mimeTypes are rejected", () => { + expect(isAllowedMimeType("application/x-evil")).toBe(false); + expect(isAllowedMimeType("image/tiff")).toBe(false); + expect(isAllowedMimeType("")).toBe(false); + }); + + test("category dispatch maps each major type and the document category", () => { + expect(attachmentCategory("image/png")).toBe("image"); + expect(attachmentCategory("video/mp4")).toBe("video"); + expect(attachmentCategory("audio/mpeg")).toBe("audio"); + expect(attachmentCategory("application/pdf")).toBe("document"); + expect(attachmentCategory("text/plain")).toBe("document"); + expect(attachmentCategory("text/csv")).toBe("document"); + expect(attachmentCategory("text/markdown")).toBe("document"); + expect(attachmentCategory("application/json")).toBe("document"); + }); + + test("category is undefined for unknown mimeTypes", () => { + expect(attachmentCategory("image/tiff")).toBeUndefined(); + }); + + test("size limits are the documented defaults", () => { + expect(PER_ATTACHMENT_LIMIT_BYTES).toBe(10 * 1024 * 1024); + expect(PER_MESSAGE_TOTAL_LIMIT_BYTES).toBe(30 * 1024 * 1024); + }); +}); diff --git a/vendor/intx-types/src/attachments.ts b/vendor/intx-types/src/attachments.ts new file mode 100644 index 000000000..ffae81706 --- /dev/null +++ b/vendor/intx-types/src/attachments.ts @@ -0,0 +1,66 @@ +// Attachment allowlist — the system-level source of truth for which MIME +// types the hub accepts as conversation attachments and which ContentBlock +// category each maps to. Adding a MIME type is a one-line change here. +// +// This is the hard capability ceiling: a type is only useful if the pipeline +// can produce the right ContentBlock and an adapter can marshal it. Per-agent +// or per-workflow narrowing rides on top of this ceiling — it narrows the +// accepted set, it never widens past what the adapters support. + +export const ATTACHMENT_CATEGORIES = [ + "image", + "video", + "audio", + "document", +] as const; +export type AttachmentCategory = (typeof ATTACHMENT_CATEGORIES)[number]; + +export const ATTACHMENT_ALLOWLIST = { + "image/png": "image", + "image/jpeg": "image", + "image/gif": "image", + "image/webp": "image", + "image/heic": "image", + "image/heif": "image", + "video/mp4": "video", + "video/webm": "video", + "video/quicktime": "video", + "audio/mpeg": "audio", + "audio/wav": "audio", + "audio/ogg": "audio", + "audio/webm": "audio", + "application/pdf": "document", + "application/json": "document", + "text/plain": "document", + "text/csv": "document", + "text/markdown": "document", +} as const satisfies Record; + +export type AllowedMimeType = keyof typeof ATTACHMENT_ALLOWLIST; + +export function isAllowedMimeType( + mimeType: string, +): mimeType is AllowedMimeType { + return mimeType in ATTACHMENT_ALLOWLIST; +} + +/** + * The ContentBlock category for an allowlisted MIME type, or `undefined` + * when the type is not on the allowlist. Callers decide how to treat an + * unknown type (the route rejects it at the boundary; turn construction + * surfaces it as a text marker). + */ +export function attachmentCategory( + mimeType: string, +): AttachmentCategory | undefined { + if (isAllowedMimeType(mimeType)) { + return ATTACHMENT_ALLOWLIST[mimeType]; + } + return undefined; +} + +// Default size limits, on decoded bytes. These are the system-level +// ceiling; a future per-agent/per-workflow policy resolves an effective +// limit that defaults to these. +export const PER_ATTACHMENT_LIMIT_BYTES = 10 * 1024 * 1024; +export const PER_MESSAGE_TOTAL_LIMIT_BYTES = 30 * 1024 * 1024; diff --git a/vendor/intx-types/src/audit.ts b/vendor/intx-types/src/audit.ts new file mode 100644 index 000000000..42cbad1a0 --- /dev/null +++ b/vendor/intx-types/src/audit.ts @@ -0,0 +1,49 @@ +import { type } from "arktype"; + +import { MatchedGrant, grantEffects } from "./grants"; + +const Effect = type.enumerated(...grantEffects); + +export const AuditAuthz = type({ + effect: Effect.or("null").describe( + "The authorization outcome the runtime resolved for this tool call: `allow`, `deny`, or `ask`, or `null` when no grant matched.", + ), + "resolvedBy?": MatchedGrant.or("null").describe( + "The single grant whose effect determined the outcome (the most specific match), or `null` when nothing matched.", + ), + matchingGrants: MatchedGrant.array(), + blocked: type("boolean").describe( + "True when the runtime prevented the tool call from executing because authorization did not resolve to `allow`.", + ), + "blockReason?": "string", +}); +export type AuditAuthz = typeof AuditAuthz.infer; + +export const AuditRecord = type({ + callId: "string", + tool: "string", + arguments: "Record", + authz: AuditAuthz.or("null"), + result: type({ + content: "string | Record", + isError: "boolean", + }), + timestamp: "string", + sessionId: "string", + // Monotonic sequence number from the reactor's tool.done event. + // Supplied by the caller; the reactor owns the sequence. + seq: "number.integer >= 0", +}); +export type AuditRecord = typeof AuditRecord.infer; + +export const ErrorRecord = type({ + source: "'inference' | 'reactor'", + category: "string", + message: "string", + "statusCode?": "number.integer", + fatal: "boolean", + timestamp: "string", + sessionId: "string", + seq: "number.integer >= 0", +}); +export type ErrorRecord = typeof ErrorRecord.infer; diff --git a/vendor/intx-types/src/authz.ts b/vendor/intx-types/src/authz.ts new file mode 100644 index 000000000..95572a694 --- /dev/null +++ b/vendor/intx-types/src/authz.ts @@ -0,0 +1,45 @@ +export type Effect = "allow" | "deny" | "ask"; + +export type GrantRule = { + id: string; + resource: string; + action: string; + effect: Effect; + origin: "system" | "role" | "creator" | "invoker"; + conditions: Record | null; + expiresAt: Date | null; + roleId: string | null; + principalId: string | null; +}; + +export type GrantStore = { + collectGrants(principalId: string, tenantId: string): Promise; + /** + * Like `collectGrants`, but unions the principal's grants across the tenant + * ancestor chain (the acting tenant plus every ancestor up to the root) + * rather than a single tenant. Only the source-resolution credential-use + * check uses this: it mirrors the ancestor-chain reach of credential + * resolution so a `credential:{id}` / `use` grant stamped with an inherited + * credential's own (ancestor) tenant still authorizes use. The general RBAC + * path stays on the single-tenant `collectGrants`. + */ + collectGrantsInChain( + principalId: string, + tenantId: string, + ): Promise; +}; + +export type ConditionContext = { + now: Date; + resource: string; + action: string; + principalId: string; + tenantId: string; +}; + +export type ConditionEvaluator = ( + value: unknown, + ctx: ConditionContext, +) => boolean | Promise; + +export type ConditionRegistry = Record; diff --git a/vendor/intx-types/src/base64.ts b/vendor/intx-types/src/base64.ts new file mode 100644 index 000000000..5158372ce --- /dev/null +++ b/vendor/intx-types/src/base64.ts @@ -0,0 +1,22 @@ +// Base64 codec for byte strings. +// +// Used to ship binary mail bodies over text-only WebSocket frames. +// Centralizing here keeps the encoding stable across the sidecar/hub +// boundary and any other consumer that needs the same wire shape. + +export function base64Encode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +export function base64Decode(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/vendor/intx-types/src/base64url.test.ts b/vendor/intx-types/src/base64url.test.ts new file mode 100644 index 000000000..e21ec0534 --- /dev/null +++ b/vendor/intx-types/src/base64url.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect } from "bun:test"; + +import { base64urlEncode, base64urlDecode } from "./base64url"; + +function makeBytes(len: number): Uint8Array { + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = (i * 31 + len * 17) % 256; + } + return bytes; +} + +// Known-answer vectors pinning exact byte->string output. Chosen to cover the +// empty input, the 1/2/3-byte padding-strip boundaries, inputs whose standard +// base64 output carries `+` and `/` (so the `-`/`_` substitution is exercised), +// and high bytes 0x80-0xFF. +const VECTORS: { bytes: number[]; b64url: string }[] = [ + { bytes: [], b64url: "" }, + { bytes: [0x66], b64url: "Zg" }, + { bytes: [0x66, 0x6f], b64url: "Zm8" }, + { bytes: [0x66, 0x6f, 0x6f], b64url: "Zm9v" }, + { bytes: [0xfb, 0xf0], b64url: "-_A" }, + { bytes: [0x3e, 0x3f, 0xbf], b64url: "Pj-_" }, + { bytes: [0xff, 0xff, 0xff], b64url: "____" }, + { bytes: [0x80, 0x81, 0xfe, 0xff], b64url: "gIH-_w" }, +]; + +describe("base64url", () => { + test("round-trips encode then decode across lengths 0-300", () => { + for (let len = 0; len <= 300; len++) { + const bytes = makeBytes(len); + const roundTripped = base64urlDecode(base64urlEncode(bytes)); + expect(roundTripped).toEqual(bytes); + } + }); + + test("encodes and decodes known-answer vectors", () => { + for (const v of VECTORS) { + const bytes = new Uint8Array(v.bytes); + expect(base64urlEncode(bytes)).toBe(v.b64url); + expect(base64urlDecode(v.b64url)).toEqual(bytes); + } + }); + + test("throws on input with non-base64 characters", () => { + expect(() => base64urlDecode("@@@@")).toThrow(); + expect(() => base64urlDecode("not valid base64!!!")).toThrow(); + }); +}); diff --git a/vendor/intx-types/src/base64url.ts b/vendor/intx-types/src/base64url.ts new file mode 100644 index 000000000..fdfacb3e3 --- /dev/null +++ b/vendor/intx-types/src/base64url.ts @@ -0,0 +1,22 @@ +// Base64url codec for byte strings (RFC 4648 section 5). +// +// URL- and filename-safe base64: standard base64 with `+`/`/` replaced by +// `-`/`_` and trailing `=` padding stripped. Used for opaque pagination +// cursors and git PAT secrets that ride in URLs and HTTP basic-auth headers, +// where the standard `+`, `/`, and `=` characters are unsafe. Reuses the +// base64 core so the two encodings stay byte-compatible. + +import { base64Decode, base64Encode } from "./base64"; + +export function base64urlEncode(bytes: Uint8Array): string { + return base64Encode(bytes) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export function base64urlDecode(s: string): Uint8Array { + const translated = s.replace(/-/g, "+").replace(/_/g, "/"); + const padLength = (4 - (translated.length % 4)) % 4; + return base64Decode(translated + "=".repeat(padLength)); +} diff --git a/vendor/intx-types/src/capabilities.test.ts b/vendor/intx-types/src/capabilities.test.ts new file mode 100644 index 000000000..f31449f2a --- /dev/null +++ b/vendor/intx-types/src/capabilities.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + CAPABILITIES, + Capability, + CURATED_CAPABILITIES, + WIRE_CAPABILITIES, +} from "./capabilities"; + +describe("capability vocabulary", () => { + test("is the wire capabilities plus the curated capabilities", () => { + expect([...CAPABILITIES]).toEqual([ + ...WIRE_CAPABILITIES, + ...CURATED_CAPABILITIES, + ]); + }); + + test("has 29 wire capabilities and 2 curated capabilities", () => { + expect(WIRE_CAPABILITIES.length).toBe(29); + expect(CURATED_CAPABILITIES.length).toBe(2); + }); + + test("contains no duplicate names", () => { + expect(new Set(CAPABILITIES).size).toBe(CAPABILITIES.length); + }); + + test("carries the curated capabilities the matrix cannot prove", () => { + expect([...CURATED_CAPABILITIES]).toEqual([ + "long-context", + "prompt-caching", + ]); + }); +}); + +describe("Capability validator", () => { + test("accepts every name in the vocabulary", () => { + for (const name of CAPABILITIES) { + expect(Capability(name)).toBe(name); + } + }); + + test("rejects safety-classification, which production does not support", () => { + // safety-classification lives only in the discovery probe vocabulary; the + // production runtime has no support for it, so the catalog must not accept + // it. This assertion pins that boundary. + expect(Capability("safety-classification") instanceof type.errors).toBe( + true, + ); + expect( + Capability("safety-classification-streaming") instanceof type.errors, + ).toBe(true); + }); + + test("rejects an unknown capability name", () => { + expect(Capability("telepathy") instanceof type.errors).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/capabilities.ts b/vendor/intx-types/src/capabilities.ts new file mode 100644 index 000000000..933819d9b --- /dev/null +++ b/vendor/intx-types/src/capabilities.ts @@ -0,0 +1,59 @@ +import { type } from "arktype"; + +// The capabilities the production inference runtime demonstrates on the wire, +// and that the discovery rig probes for. This is the single source of truth for +// the shared capability vocabulary: @intx/inference-discovery imports this list +// and extends it, so production code never has to depend on the discovery +// package. Each capability that has a streaming wire flow distinct from its +// buffered one carries a paired `-streaming` variant; `function-calling` is the +// sole base with no streaming pair (a bare tool call has no delta flow to +// capture). +export const WIRE_CAPABILITIES = [ + "plain-text", + "plain-text-streaming", + "function-calling", + "function-calling-multi-turn", + "function-calling-multi-turn-streaming", + "function-calling-with-thinking", + "function-calling-with-thinking-streaming", + "vision-input", + "vision-input-streaming", + "audio-input", + "audio-input-streaming", + "video-input", + "video-input-streaming", + "document-input", + "document-input-streaming", + "image-output", + "image-output-streaming", + "code-execution", + "code-execution-streaming", + "reasoning-content", + "reasoning-content-streaming", + "grounding", + "grounding-streaming", + "files-api-reference", + "files-api-reference-streaming", + "redacted-thinking", + "redacted-thinking-streaming", + "structured-output", + "structured-output-streaming", +] as const; + +// Capabilities a model has that are not observable on the wire and cannot be +// proven by a discovery fixture. `long-context` denotes a model advertising a +// context window of at least ~200k tokens (a curation criterion, not a stored +// limit); `prompt-caching` denotes provider-side prompt caching. The discovery +// rig has no probe that could prove either, so operators curate them by hand. +export const CURATED_CAPABILITIES = ["long-context", "prompt-caching"] as const; + +export const CAPABILITIES = [ + ...WIRE_CAPABILITIES, + ...CURATED_CAPABILITIES, +] as const; +export type Capability = (typeof CAPABILITIES)[number]; +export const Capability = type + .enumerated(...CAPABILITIES) + .describe( + "A capability a provider advertises for a model: a wire capability the inference runtime supports, or one of the curated tags `long-context` and `prompt-caching`.", + ); diff --git a/vendor/intx-types/src/catalog.test.ts b/vendor/intx-types/src/catalog.test.ts new file mode 100644 index 000000000..57295fc3c --- /dev/null +++ b/vendor/intx-types/src/catalog.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + ModelOfferingResponse, + ModelProviderPlugin, + modelProviderPlugins, + ModelProviderResponse, + ModelRequirement, + ModelRequirements, + PricingRowResponse, +} from "./catalog"; + +describe("ModelProviderPlugin enum", () => { + test("accepts each adapter key", () => { + for (const plugin of modelProviderPlugins) { + expect(ModelProviderPlugin(plugin)).toBe(plugin); + } + }); + + test("rejects an unknown plugin", () => { + expect(ModelProviderPlugin("cohere") instanceof type.errors).toBe(true); + }); +}); + +describe("ModelOfferingResponse", () => { + const base = { + id: "mof_1", + tenantId: "ten_1", + modelId: "mdl_1", + providerId: "mpv_1", + priority: 0, + deploymentTags: [], + capabilities: ["vision-input", "function-calling-multi-turn"], + quirks: null, + disabled: false, + createdAt: "2026-06-18T00:00:00Z", + updatedAt: "2026-06-18T00:00:00Z", + }; + + test("accepts curated capabilities", () => { + expect(ModelOfferingResponse(base) instanceof type.errors).toBe(false); + }); + + test("rejects a non-curated capability", () => { + const bad = { ...base, capabilities: ["telepathy"] }; + expect(ModelOfferingResponse(bad) instanceof type.errors).toBe(true); + }); + + test("accepts a populated quirks bag", () => { + const withQuirks = { + ...base, + quirks: { forceAssistantReasoningContent: true }, + }; + expect(ModelOfferingResponse(withQuirks) instanceof type.errors).toBe( + false, + ); + }); +}); + +describe("ModelProviderResponse", () => { + const base = { + id: "mpv_1", + tenantId: "ten_1", + name: "Anthropic direct", + plugin: "anthropic", + baseURL: "https://api.anthropic.com", + disabled: false, + createdAt: "2026-06-18T00:00:00Z", + updatedAt: "2026-06-18T00:00:00Z", + }; + + test("accepts a credential-backed provider", () => { + const row = { ...base, credentialId: "cred_1" }; + expect(ModelProviderResponse(row) instanceof type.errors).toBe(false); + }); + + test("accepts a wallet-backed provider", () => { + const row = { ...base, walletId: "wal_1" }; + expect(ModelProviderResponse(row) instanceof type.errors).toBe(false); + }); +}); + +describe("ModelRequirement", () => { + test("accepts a bare model name", () => { + expect(ModelRequirement({ model: "opus" }) instanceof type.errors).toBe( + false, + ); + }); + + test("accepts a capability filter and a provider preference", () => { + const req = { + model: "opus", + capabilities: ["vision-input", "function-calling-multi-turn"], + providers: { mode: "pin", order: ["anthropic"] }, + }; + expect(ModelRequirement(req) instanceof type.errors).toBe(false); + }); + + test("rejects a non-curated capability", () => { + const req = { model: "opus", capabilities: ["telepathy"] }; + expect(ModelRequirement(req) instanceof type.errors).toBe(true); + }); + + test("rejects an unknown preference mode", () => { + const req = { model: "opus", providers: { mode: "force", order: [] } }; + expect(ModelRequirement(req) instanceof type.errors).toBe(true); + }); + + test("requires the model name", () => { + expect(ModelRequirement({}) instanceof type.errors).toBe(true); + }); +}); + +describe("ModelRequirements", () => { + test("accepts distinct model names", () => { + const reqs = [{ model: "opus" }, { model: "sonnet" }]; + expect(ModelRequirements(reqs) instanceof type.errors).toBe(false); + }); + + test("rejects two requirements for the same model", () => { + const reqs = [ + { model: "opus", capabilities: ["vision-input"] }, + { model: "opus", capabilities: ["function-calling-multi-turn"] }, + ]; + expect(ModelRequirements(reqs) instanceof type.errors).toBe(true); + }); + + test("accepts an empty array", () => { + expect(ModelRequirements([]) instanceof type.errors).toBe(false); + }); +}); + +describe("PricingRowResponse", () => { + const base = { + id: "prc_1", + tenantId: "ten_1", + offeringId: "mof_1", + currency: "USD", + effectiveFrom: "2026-06-18T00:00:00Z", + createdAt: "2026-06-18T00:00:00Z", + }; + + test("accepts a row with a subset of fee axes", () => { + const row = { ...base, inputTokenPrice: "0.000003", perImageFee: null }; + expect(PricingRowResponse(row) instanceof type.errors).toBe(false); + }); + + test("requires effectiveFrom", () => { + const { effectiveFrom: _omit, ...without } = base; + expect(PricingRowResponse(without) instanceof type.errors).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/catalog.ts b/vendor/intx-types/src/catalog.ts new file mode 100644 index 000000000..47d1c3717 --- /dev/null +++ b/vendor/intx-types/src/catalog.ts @@ -0,0 +1,254 @@ +import { type } from "arktype"; + +import { Capability } from "./capabilities"; + +export const modelProviderPlugins = [ + "anthropic", + "openai", + "openai-compatible", + "google-genai", +] as const; +export type ModelProviderPlugin = (typeof modelProviderPlugins)[number]; +export const ModelProviderPlugin = type + .enumerated(...modelProviderPlugins) + .describe( + "The inference adapter that serves this provider's models, dispatched by the runtime provider registry.", + ); + +export const providerPreferenceModes = ["pin", "prefer"] as const; +export type ProviderPreferenceMode = (typeof providerPreferenceModes)[number]; + +export const ProviderPreference = type({ + mode: type + .enumerated(...providerPreferenceModes) + .describe( + "`pin` restricts resolution to the listed providers and fails over only among them; `prefer` orders the listed providers first but keeps the rest of the tenant's providers as fallback.", + ), + order: type("string[]").describe( + "Model-provider names in preferred order, most preferred first.", + ), +}); +export type ProviderPreference = typeof ProviderPreference.infer; + +export const ModelRequirement = type({ + model: type("string").describe( + "Canonical model name the agent requires for inference.", + ), + "capabilities?": Capability.array().describe( + "An offering must advertise every one of these capabilities to be eligible to serve this requirement.", + ), + "providers?": ProviderPreference.describe( + "The definition author's provider preference for this model. Resolution applies it over the tenant-visible providers; it cannot introduce a provider the tenant catalog does not contain.", + ), +}); +export type ModelRequirement = typeof ModelRequirement.infer; + +// A definition declares at most one requirement per canonical model: two +// requirements for the same model would resolve the same offering twice and +// produce duplicate inference-source ids. Reject the ambiguity here, at the +// boundary, rather than letting it surface deep in source resolution. +export const ModelRequirements = ModelRequirement.array().narrow( + (reqs, ctx) => { + const seen = new Set(); + for (const req of reqs) { + if (seen.has(req.model)) { + return ctx.mustBe( + `an array with no duplicate model requirements; "${req.model}" appears more than once`, + ); + } + seen.add(req.model); + } + return true; + }, +); +export type ModelRequirements = typeof ModelRequirements.infer; + +export const InvokerModelPreference = type({ + model: type("string").describe( + "Canonical model name this launch-time preference applies to.", + ), + providers: ProviderPreference, +}); +export type InvokerModelPreference = typeof InvokerModelPreference.infer; + +export const InvokerModelPreferences = InvokerModelPreference.array(); +export type InvokerModelPreferences = typeof InvokerModelPreferences.infer; + +export const CreateModel = type({ + canonicalName: type("string").describe( + "Tenant-unique canonical model name agents match their requirements against.", + ), + "displayName?": "string | null", + "description?": "string | null", +}); + +export const UpdateModel = type({ + "displayName?": "string | null", + "description?": "string | null", + "disabled?": "boolean", +}); + +export const CreateModelProvider = type({ + name: type("string").describe("Tenant-unique model-provider name."), + plugin: ModelProviderPlugin, + baseURL: "string", + // Exactly one of these must be set; the route rejects a body that sets + // both or neither before touching the database. + "credentialId?": "string | null", + "walletId?": "string | null", +}); + +export const UpdateModelProvider = type({ + "name?": "string", + "baseURL?": "string", + "disabled?": "boolean", +}); + +export const CreateModelOffering = type({ + modelId: type("string").describe( + "Catalog id of a model owned by this tenant.", + ), + providerId: type("string").describe( + "Catalog id of a model-provider owned by this tenant.", + ), + "priority?": type("number").describe( + "Ordering hint for source resolution; lower values are preferred first. Defaults to 0.", + ), + "deploymentTags?": "string[]", + "capabilities?": Capability.array(), + "quirks?": type("Record").describe( + "Opaque per-deployment adapter accommodations for this offering; the adapter factory validates the provider-specific shape. Omit when the deployment needs none.", + ), +}); + +export const UpdateModelOffering = type({ + "priority?": "number", + "deploymentTags?": "string[]", + "capabilities?": Capability.array(), + "quirks?": type("Record | null").describe( + "Replacement quirks bag, or null to clear it back to the adapter's default behavior. Omit to leave unchanged.", + ), + "disabled?": "boolean", +}); + +const createPriceDescription = (axis: string): string => + `${axis} as a decimal string in this row's \`currency\`, or null if this provider does not charge for it.`; + +export const CreatePricingRow = type({ + currency: type("string").describe( + "Fiat currency code or opaque credit unit this row prices in.", + ), + "effectiveFrom?": type("string").describe( + "ISO-8601 timestamp from which this price applies. Defaults to the time of the request.", + ), + "inputTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per input token"), + ), + "outputTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per output token"), + ), + "cacheReadTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per cached-read token"), + ), + "cacheWriteTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per cached-write token"), + ), + "thinkingTokenPrice?": type("string | null").describe( + createPriceDescription("Cost per thinking token"), + ), + "perRequestFee?": type("string | null").describe( + createPriceDescription("Flat fee per request"), + ), + "perImageFee?": type("string | null").describe( + createPriceDescription("Fee per image"), + ), + "perAudioFee?": type("string | null").describe( + createPriceDescription("Fee per audio unit"), + ), +}); + +export const ModelResponse = type({ + id: "string", + tenantId: "string", + canonicalName: "string", + "displayName?": "string | null", + "description?": "string | null", + disabled: "boolean", + createdAt: "string", + updatedAt: "string", +}); + +export const ModelProviderResponse = type({ + id: "string", + tenantId: "string", + name: "string", + plugin: ModelProviderPlugin, + baseURL: "string", + // Exactly one of these is set (enforced at the database). They are opaque + // references to a credential or wallet row, not secret material. + "credentialId?": "string | null", + "walletId?": "string | null", + disabled: "boolean", + createdAt: "string", + updatedAt: "string", +}); + +export const ModelOfferingResponse = type({ + id: "string", + tenantId: "string", + modelId: "string", + providerId: "string", + priority: type("number").describe( + "Ordering hint for source resolution; lower values are preferred first.", + ), + deploymentTags: "string[]", + capabilities: Capability.array().describe( + "Curated capability tags this provider advertises for this model.", + ), + quirks: type("Record | null").describe( + "Opaque per-deployment adapter accommodations, or null when the deployment needs none.", + ), + disabled: "boolean", + createdAt: "string", + updatedAt: "string", +}); + +const priceDescription = (axis: string): string => + `${axis} as a decimal string in the row's \`currency\`, or null if this provider does not charge for it.`; + +export const PricingRowResponse = type({ + id: "string", + tenantId: "string", + offeringId: "string", + currency: type("string").describe( + "Fiat currency code or opaque credit unit this row prices in.", + ), + "inputTokenPrice?": type("string | null").describe( + priceDescription("Cost per input token"), + ), + "outputTokenPrice?": type("string | null").describe( + priceDescription("Cost per output token"), + ), + "cacheReadTokenPrice?": type("string | null").describe( + priceDescription("Cost per cached-read token"), + ), + "cacheWriteTokenPrice?": type("string | null").describe( + priceDescription("Cost per cached-write token"), + ), + "thinkingTokenPrice?": type("string | null").describe( + priceDescription("Cost per thinking token"), + ), + "perRequestFee?": type("string | null").describe( + priceDescription("Flat fee per request"), + ), + "perImageFee?": type("string | null").describe( + priceDescription("Fee per image"), + ), + "perAudioFee?": type("string | null").describe( + priceDescription("Fee per audio unit"), + ), + effectiveFrom: type("string").describe( + "ISO-8601 timestamp from which this price applies. Cost attribution at a past time uses the latest row whose effectiveFrom is at or before that time.", + ), + createdAt: "string", +}); diff --git a/vendor/intx-types/src/common.ts b/vendor/intx-types/src/common.ts new file mode 100644 index 000000000..30338016f --- /dev/null +++ b/vendor/intx-types/src/common.ts @@ -0,0 +1,34 @@ +import { type, type Type } from "arktype"; + +export const ErrorResponse = type({ + error: { + code: "string", + message: "string", + }, +}); + +export const PaginationParams = type({ + "cursor?": "string", + "limit?": "string", +}); + +export const PaginatedList = type({ + data: "unknown[]", + nextCursor: "string | null", +}); + +/** + * Creates a typed paginated response schema for use with OpenAPI. + * Wraps an item array schema in `{ data: T[], nextCursor: string | null }`. + */ +export function paginatedSchema(itemSchema: Type) { + return type({ + data: itemSchema.array(), + nextCursor: "string | null", + }); +} + +export const Timestamps = type({ + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/concat.test.ts b/vendor/intx-types/src/concat.test.ts new file mode 100644 index 000000000..306451247 --- /dev/null +++ b/vendor/intx-types/src/concat.test.ts @@ -0,0 +1,33 @@ +import { describe, test, expect } from "bun:test"; + +import { concatBytes } from "./concat"; + +describe("concatBytes", () => { + test("returns an empty array for an empty list", () => { + const out = concatBytes([]); + expect(out).toEqual(new Uint8Array(0)); + }); + + test("returns the single chunk unchanged in content", () => { + const out = concatBytes([new Uint8Array([1, 2, 3])]); + expect(out).toEqual(new Uint8Array([1, 2, 3])); + }); + + test("joins multiple chunks preserving order and bytes", () => { + const out = concatBytes([ + new Uint8Array([1, 2]), + new Uint8Array([3]), + new Uint8Array([4, 5, 6]), + ]); + expect(out).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + }); + + test("skips empty chunks without disturbing the result", () => { + const out = concatBytes([ + new Uint8Array([0xff]), + new Uint8Array(0), + new Uint8Array([0x00, 0x80]), + ]); + expect(out).toEqual(new Uint8Array([0xff, 0x00, 0x80])); + }); +}); diff --git a/vendor/intx-types/src/concat.ts b/vendor/intx-types/src/concat.ts new file mode 100644 index 000000000..cd3c0166c --- /dev/null +++ b/vendor/intx-types/src/concat.ts @@ -0,0 +1,19 @@ +// Concatenate byte arrays into a single Uint8Array. +// +// A Web-standard replacement for Node's `Buffer.concat`: sum the chunk +// lengths, allocate the result once, and copy each chunk in at its +// running offset so the bytes land in input order. + +export function concatBytes(chunks: Uint8Array[]): Uint8Array { + let total = 0; + for (const chunk of chunks) { + total += chunk.length; + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} diff --git a/vendor/intx-types/src/content-type.test.ts b/vendor/intx-types/src/content-type.test.ts new file mode 100644 index 000000000..360f2c39c --- /dev/null +++ b/vendor/intx-types/src/content-type.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from "bun:test"; +import { detectResponseKind } from "./content-type"; + +describe("detectResponseKind", () => { + test("returns 'json' for application/json", () => { + const headers = new Headers({ "content-type": "application/json" }); + expect(detectResponseKind(headers)).toBe("json"); + }); + + test("returns 'json' for application/json with charset", () => { + const headers = new Headers({ + "content-type": "application/json; charset=utf-8", + }); + expect(detectResponseKind(headers)).toBe("json"); + }); + + test("returns 'sse' for text/event-stream", () => { + const headers = new Headers({ "content-type": "text/event-stream" }); + expect(detectResponseKind(headers)).toBe("sse"); + }); + + test("returns 'sse' for text/event-stream with charset", () => { + const headers = new Headers({ + "content-type": "text/event-stream; charset=utf-8", + }); + expect(detectResponseKind(headers)).toBe("sse"); + }); + + test("matches Content-Type case-insensitively", () => { + const headers = new Headers({ "content-type": "Application/JSON" }); + expect(detectResponseKind(headers)).toBe("json"); + }); + + test("throws when content-type is missing", () => { + const headers = new Headers(); + expect(() => detectResponseKind(headers)).toThrow( + /response has no Content-Type/, + ); + }); + + test("throws on unknown content-type", () => { + const headers = new Headers({ "content-type": "text/plain" }); + expect(() => detectResponseKind(headers)).toThrow(/text\/plain/); + }); +}); diff --git a/vendor/intx-types/src/content-type.ts b/vendor/intx-types/src/content-type.ts new file mode 100644 index 000000000..1dd9e52f1 --- /dev/null +++ b/vendor/intx-types/src/content-type.ts @@ -0,0 +1,20 @@ +export type ResponseKind = "sse" | "json"; + +export function detectResponseKind(headers: Headers): ResponseKind { + const raw = headers.get("content-type"); + if (raw === null) { + throw new Error( + "Cannot detect response kind: response has no Content-Type header", + ); + } + const normalized = raw.trim().toLowerCase(); + if (normalized.startsWith("text/event-stream")) { + return "sse"; + } + if (normalized.startsWith("application/json")) { + return "json"; + } + throw new Error( + `Unsupported response Content-Type: ${raw}. Expected text/event-stream or application/json.`, + ); +} diff --git a/vendor/intx-types/src/credentials.test.ts b/vendor/intx-types/src/credentials.test.ts new file mode 100644 index 000000000..6402fe90b --- /dev/null +++ b/vendor/intx-types/src/credentials.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; +import { + credentialRequirementSources, + CredentialRequirement, +} from "./credentials"; + +// --------------------------------------------------------------------------- +// 1. Source enum +// --------------------------------------------------------------------------- + +describe("source enums", () => { + test("credentialRequirementSources includes tenant, creator, invoker", () => { + expect([...credentialRequirementSources]).toEqual([ + "tenant", + "creator", + "invoker", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// 2. CredentialRequirement accepts tenant source +// --------------------------------------------------------------------------- + +describe("CredentialRequirement validator", () => { + test("accepts tenant source", () => { + const result = CredentialRequirement({ + providerName: "Anthropic", + source: "tenant", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts creator source", () => { + const result = CredentialRequirement({ + providerName: "Anthropic", + source: "creator", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts invoker source", () => { + const result = CredentialRequirement({ + providerName: "Anthropic", + source: "invoker", + }); + expect(result instanceof type.errors).toBe(false); + }); +}); diff --git a/vendor/intx-types/src/credentials.ts b/vendor/intx-types/src/credentials.ts new file mode 100644 index 000000000..b8babec06 --- /dev/null +++ b/vendor/intx-types/src/credentials.ts @@ -0,0 +1,98 @@ +import { type } from "arktype"; + +export const credentialTypes = [ + "api_key", + "oauth_token", + "certificate", + "other", +] as const; +export type CredentialType = (typeof credentialTypes)[number]; + +export const credentialStatuses = [ + "active", + "expired", + "revoked", + "error", +] as const; +export type CredentialStatus = (typeof credentialStatuses)[number]; + +export const credentialRequirementSources = [ + "tenant", + "creator", + "invoker", +] as const; +export type CredentialRequirementSource = + (typeof credentialRequirementSources)[number]; + +const CredType = type.enumerated(...credentialTypes); +const CredStatus = type.enumerated(...credentialStatuses); +const CredentialSourceType = type.enumerated(...credentialRequirementSources); + +const credentialTypeDescription = + "Kind of secret material this credential holds: `api_key`, `oauth_token`, `certificate`, or `other`. Determines how `secret` (and `refreshSecret` for OAuth) is interpreted when the credential is used."; + +const credentialStatusDescription = + "Usability state of the credential: `active` (usable), `expired` (past its `expiresAt`), `revoked` (deliberately invalidated), or `error` (last use failed, e.g. rejected by the provider)."; + +const credentialScopesDescription = + "Permissions granted to this credential by the provider (for example OAuth scopes). Informational on the credential record; the provider is the authority on what the secret can actually do."; + +const credentialMetadataDescription = + "Free-form provider- or integration-specific data attached to the credential. Not interpreted by the hub."; + +export const CreateCredential = type({ + providerId: "string", + name: "string", + type: CredType.describe(credentialTypeDescription), + "principalId?": "string", + "oauthClientId?": "string", + "description?": "string", + secret: "string", + "refreshSecret?": "string", + "scopes?": type("string[]").describe(credentialScopesDescription), + "expiresAt?": "string", + "metadata?": type("Record").describe( + credentialMetadataDescription, + ), +}); + +export const UpdateCredential = type({ + "name?": "string", + "description?": "string", + "secret?": "string", + "refreshSecret?": "string | null", + "scopes?": type("string[] | null").describe(credentialScopesDescription), + "expiresAt?": "string | null", + "status?": CredStatus.describe(credentialStatusDescription), + "metadata?": type("Record").describe( + credentialMetadataDescription, + ), +}); + +export const CredentialResponse = type({ + id: "string", + tenantId: "string", + providerId: "string", + "principalId?": "string | null", + "oauthClientId?": "string | null", + name: "string", + type: CredType.describe(credentialTypeDescription), + "description?": "string | null", + "scopes?": type("string[] | null").describe(credentialScopesDescription), + "expiresAt?": "string | null", + status: CredStatus.describe(credentialStatusDescription), + "metadata?": type("Record | null").describe( + credentialMetadataDescription, + ), + createdAt: "string", + updatedAt: "string", +}); + +export const CredentialRequirement = type({ + providerName: "string", + "scopes?": "string[]", + source: CredentialSourceType.describe( + "Whose credential satisfies this requirement at launch: `tenant` (a credential owned by the tenant), `creator` (the definition author's), or `invoker` (whoever launched the agent).", + ), + "name?": "string", +}); diff --git a/vendor/intx-types/src/grant-wire.ts b/vendor/intx-types/src/grant-wire.ts new file mode 100644 index 000000000..a5e5f3891 --- /dev/null +++ b/vendor/intx-types/src/grant-wire.ts @@ -0,0 +1,29 @@ +// Arktype validators for GrantRule wire serialization. +// +// GrantRule.expiresAt is a Date | null at runtime, but JSON round-trips +// turn it into a string | null. This validator accepts either form and +// coerces strings back to Date instances, making it safe to use when +// deserializing grants that have round-tripped through JSON. + +import { type } from "arktype"; + +import { grantEffects, grantOrigins } from "./grants"; + +const Effect = type.enumerated(...grantEffects); +const Origin = type.enumerated(...grantOrigins); + +const DateOrNull = type("Date | null").or(type("string.date.parse")); + +export const WireGrantRule = type({ + id: "string", + resource: "string", + action: "string", + effect: Effect, + origin: Origin, + conditions: "Record | null", + expiresAt: DateOrNull, + roleId: "string | null", + principalId: "string | null", +}); + +export type WireGrantRule = typeof WireGrantRule.infer; diff --git a/vendor/intx-types/src/grants.test.ts b/vendor/intx-types/src/grants.test.ts new file mode 100644 index 000000000..9cc42ac92 --- /dev/null +++ b/vendor/intx-types/src/grants.test.ts @@ -0,0 +1,128 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; +import { grantRequirementSources, GrantRequirement } from "./grants"; + +// --------------------------------------------------------------------------- +// 1. Source enum +// --------------------------------------------------------------------------- + +describe("source enums", () => { + test("grantRequirementSources includes only creator and invoker", () => { + expect([...grantRequirementSources]).toEqual(["creator", "invoker"]); + }); +}); + +// --------------------------------------------------------------------------- +// 2. GrantRequirement validation +// --------------------------------------------------------------------------- + +describe("GrantRequirement validator", () => { + test("accepts a minimal valid requirement", () => { + const result = GrantRequirement({ + resource: "tool:bash", + action: "invoke", + source: "creator", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts creator and invoker sources", () => { + for (const source of ["creator", "invoker"] as const) { + const result = GrantRequirement({ + resource: "wallet:*", + action: "spend", + source, + }); + expect(result instanceof type.errors).toBe(false); + } + }); + + test("rejects tenant source", () => { + const result = GrantRequirement({ + resource: "tool:bash", + action: "invoke", + source: "tenant", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts a fully populated requirement", () => { + const result = GrantRequirement({ + resource: "credential:crd_stripe", + action: "use", + effect: "allow", + source: "creator", + conditions: { max_spend_per_day: 100, currency: "USD" }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts null conditions", () => { + const result = GrantRequirement({ + resource: "tool:*", + action: "invoke", + source: "invoker", + conditions: null, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an unknown source", () => { + const result = GrantRequirement({ + resource: "tool:bash", + action: "invoke", + source: "system", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an unknown effect", () => { + const result = GrantRequirement({ + resource: "tool:bash", + action: "invoke", + source: "creator", + effect: "maybe", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects missing resource", () => { + const result = GrantRequirement({ + action: "invoke", + source: "creator", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects missing action", () => { + const result = GrantRequirement({ + resource: "tool:bash", + source: "creator", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects missing source", () => { + const result = GrantRequirement({ + resource: "tool:bash", + action: "invoke", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("effect is optional", () => { + const withoutEffect = GrantRequirement({ + resource: "tool:bash", + action: "invoke", + source: "creator", + }); + const withEffect = GrantRequirement({ + resource: "tool:bash", + action: "invoke", + source: "creator", + effect: "ask", + }); + expect(withoutEffect instanceof type.errors).toBe(false); + expect(withEffect instanceof type.errors).toBe(false); + }); +}); diff --git a/vendor/intx-types/src/grants.ts b/vendor/intx-types/src/grants.ts new file mode 100644 index 000000000..bc505fa31 --- /dev/null +++ b/vendor/intx-types/src/grants.ts @@ -0,0 +1,103 @@ +import { type } from "arktype"; + +export const grantEffects = ["allow", "deny", "ask"] as const; +export type GrantEffect = (typeof grantEffects)[number]; + +export const grantOrigins = ["system", "role", "creator", "invoker"] as const; +export type GrantOrigin = (typeof grantOrigins)[number]; + +export const grantRequirementSources = ["creator", "invoker"] as const; +export type GrantRequirementSource = (typeof grantRequirementSources)[number]; + +const Effect = type.enumerated(...grantEffects); +const Origin = type.enumerated(...grantOrigins); +const GrantSourceType = type.enumerated(...grantRequirementSources); + +const effectDescription = + "Outcome when this grant is the one resolved for a request: `allow` permits the action, `deny` blocks it, `ask` requires interactive approval before proceeding. When several grants match, the most specific wins, and at equal specificity the strongest effect wins (`deny` over `ask` over `allow`)."; + +const originDescription = + "Records where the grant came from: `system` (built-in), `role` (granted via a role), `creator` (from the agent definition author), or `invoker` (delegated by whoever launched the agent). Origin is provenance only; it does not affect evaluation precedence."; + +const conditionsDescription = + "Optional map of named conditions that must all pass for the grant to apply, evaluated against a condition registry at authorization time. A grant with conditions is skipped (fails closed) when no registry is available to evaluate them."; + +const specificityDescription = + "Computed match-strength score used to rank grants: the count of non-wildcard characters in the resource and action patterns, with exact (wildcard-free) patterns scored far above prefix globs. Higher wins; ties are broken by effect priority."; + +export const CreateGrant = type({ + "roleId?": "string | null", + "principalId?": "string | null", + resource: "string", + action: "string", + effect: Effect.describe(effectDescription), + "conditions?": type("Record | null").describe( + conditionsDescription, + ), + origin: Origin.describe(originDescription), + "expiresAt?": "string | null", +}); + +export const UpdateGrant = type({ + "effect?": Effect.describe(effectDescription), + "conditions?": type("Record | null").describe( + conditionsDescription, + ), + "expiresAt?": "string | null", +}); + +export const GrantResponse = type({ + id: "string", + tenantId: "string", + "roleId?": "string | null", + "roleName?": "string | null", + "principalId?": "string | null", + "principalName?": "string | null", + resource: "string", + action: "string", + effect: Effect.describe(effectDescription), + "conditions?": type("Record | null").describe( + conditionsDescription, + ), + origin: Origin.describe(originDescription), + "expiresAt?": "string | null", + createdAt: "string", + updatedAt: "string", +}); + +export const EvaluateRequest = type({ + resource: "string", + action: "string", +}); + +export const MatchedGrant = type({ + id: "string", + resource: "string", + action: "string", + effect: Effect.describe(effectDescription), + origin: Origin.describe(originDescription), + "specificity?": type("number").describe(specificityDescription), +}); +export type MatchedGrant = typeof MatchedGrant.infer; + +export const EvaluateResult = type({ + effect: Effect.describe( + "The resolved outcome for the query: the effect of the winning grant, or `deny` when no grant matched (authorization fails closed).", + ), + matchingGrants: MatchedGrant.array().describe( + "Every grant that matched the requested resource and action, including the one that won. Useful for debugging why a request was allowed, denied, or required approval.", + ), +}); + +export const GrantRequirement = type({ + resource: "string", + action: "string", + "effect?": Effect.describe( + "Effect to assign the materialized grant: `allow`, `deny`, or `ask`. Defaults to `allow` when omitted.", + ), + source: GrantSourceType.describe( + "Whose authority the grant is resolved against at launch: `creator` (the definition author) or `invoker` (whoever launched the agent). The requirement is only satisfied if that party actually holds the requested capability.", + ), + "conditions?": "Record | null", +}); +export type GrantRequirement = typeof GrantRequirement.infer; diff --git a/vendor/intx-types/src/has-code.ts b/vendor/intx-types/src/has-code.ts new file mode 100644 index 000000000..718dfa3b4 --- /dev/null +++ b/vendor/intx-types/src/has-code.ts @@ -0,0 +1,11 @@ +// Type guard for errors with a Node-style `{ code: string }` shape, +// as thrown by Node.js (POSIX errno), isomorphic-git, and similar. + +export function hasCode(err: unknown): err is { code: string } { + return ( + typeof err === "object" && + err !== null && + "code" in err && + typeof (err as { code: unknown }).code === "string" + ); +} diff --git a/vendor/intx-types/src/hex.ts b/vendor/intx-types/src/hex.ts new file mode 100644 index 000000000..15b3add26 --- /dev/null +++ b/vendor/intx-types/src/hex.ts @@ -0,0 +1,25 @@ +// Hex codec for byte strings. +// +// Used across the codebase for Ed25519 key serialization, challenge +// nonces, and signatures on the wire. Centralizing here keeps the +// encoding stable and the error wording consistent. + +export function hexEncode(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function hexDecode(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error(`hexDecode: odd-length input (${hex.length} chars)`); + } + if (!/^[0-9a-fA-F]*$/.test(hex)) { + throw new Error("hexDecode: input contains non-hex characters"); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} diff --git a/vendor/intx-types/src/index.ts b/vendor/intx-types/src/index.ts new file mode 100644 index 000000000..d0abcf99c --- /dev/null +++ b/vendor/intx-types/src/index.ts @@ -0,0 +1,32 @@ +export * from "./common"; +export * from "./me"; +export * from "./tenants"; +export * from "./principals"; +export * from "./roles"; +export * from "./grants"; +export * from "./signals"; +export * from "./instances"; +export * from "./workflows"; +export * from "./attachments"; +export * from "./sessions"; +export * from "./approvals"; +export * from "./wallets"; +export * from "./providers"; +export * from "./oauth-clients"; +export * from "./credentials"; +export * from "./assets"; +export * from "./offerings"; +export * from "./models"; +export * from "./capabilities"; +export * from "./catalog"; +export * from "./observability"; +export * from "./agent-address"; +export * from "./agent-data"; +export * from "./hex"; +export * from "./message-id"; +export * from "./workflow-run-id"; +export * from "./base64"; +export * from "./base64url"; +export * from "./concat"; +export * from "./has-code"; +export * from "./audit"; diff --git a/vendor/intx-types/src/instances.test.ts b/vendor/intx-types/src/instances.test.ts new file mode 100644 index 000000000..3503059bb --- /dev/null +++ b/vendor/intx-types/src/instances.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; +import { CreateWorkflowRun } from "./instances"; + +// --------------------------------------------------------------------------- +// CreateWorkflowRun +// --------------------------------------------------------------------------- + +describe("CreateWorkflowRun", () => { + test("accepts invokerGrants array", () => { + const result = CreateWorkflowRun({ + definitionId: "wfd_1", + invokerGrants: [{ resource: "wallet:wal_1", action: "spend" }], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts invokerGrants with effect", () => { + const result = CreateWorkflowRun({ + definitionId: "wfd_1", + invokerGrants: [ + { resource: "tool:bash", action: "invoke", effect: "allow" }, + ], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts absent invokerGrants (optional)", () => { + const result = CreateWorkflowRun({ definitionId: "wfd_1" }); + expect(result instanceof type.errors).toBe(false); + }); +}); diff --git a/vendor/intx-types/src/instances.ts b/vendor/intx-types/src/instances.ts new file mode 100644 index 000000000..fe14cbda3 --- /dev/null +++ b/vendor/intx-types/src/instances.ts @@ -0,0 +1,56 @@ +import { type } from "arktype"; +import { InvokerModelPreferences } from "./catalog"; +import { grantEffects } from "./grants"; + +const Effect = type.enumerated(...grantEffects); + +export const workflowRunStatuses = [ + "deployed", + "running", + "updating", + "error", + "stopped", +] as const; +export type WorkflowRunStatus = (typeof workflowRunStatuses)[number]; + +const WorkflowRunStatusType = type.enumerated(...workflowRunStatuses); + +export const CreateWorkflowRun = type({ + definitionId: "string", + "modelPreferences?": InvokerModelPreferences.describe( + "The invoker's per-model provider preferences for this launch. Applied over the tenant-visible providers after the definition's preferences; it can only reorder or restrict, never introduce a provider the tenant catalog lacks. Persisted on the run so re-resolution reuses it.", + ), + "invokerGrants?": type({ + resource: "string", + action: "string", + "effect?": Effect, + "conditions?": "Record | null", + }) + .array() + .describe( + "Capabilities the invoker is willing to delegate to the run, resolved against the invoker's own authority at launch. These are materialized as grants on the run principal in addition to any grants from the definition's own requirements.", + ), +}); + +export const WorkflowRunResponse = type({ + id: "string", + definitionId: "string", + definitionName: "string", + tenantId: "string", + address: "string", + status: WorkflowRunStatusType.describe( + "Lifecycle state of this run: `deployed` (provisioned on a sidecar, not yet started), `running` (started and serving), `updating` (rolling to a new definition version), `error` (launch or runtime failure), or `stopped` (undeployed).", + ), + "publicKey?": "string | null", + "kernelId?": "string | null", + "sidecarId?": "string | null", + createdAt: "string", + updatedAt: "string", + "endedAt?": "string | null", +}); + +export const WorkflowRunHealth = type({ + liveness: "'ok' | 'unhealthy'", + readiness: "'ok' | 'not_ready' | 'unhealthy'", + "lastCheckedAt?": "string | null", +}); diff --git a/vendor/intx-types/src/me.ts b/vendor/intx-types/src/me.ts new file mode 100644 index 000000000..e5091b20d --- /dev/null +++ b/vendor/intx-types/src/me.ts @@ -0,0 +1,60 @@ +import { type } from "arktype"; + +export const UserProfile = type({ + id: "string", + name: "string", + email: "string", + emailVerified: "boolean", + "image?": "string | null", + createdAt: "string", + updatedAt: "string", +}); + +export const PrincipalSummary = type({ + principalId: "string", + tenantId: "string", + tenantName: "string", + tenantSlug: "string", + kind: "'user' | 'agent'", + status: "'active' | 'suspended' | 'invited' | 'deactivated'", + roles: type({ + id: "string", + name: "string", + }).array(), +}); + +export const WorkflowRunSummary = type({ + id: "string", + tenantId: "string", + tenantName: "string", + definitionId: "string", + definitionName: "string", + address: "string", + status: "'deployed' | 'running' | 'updating' | 'error' | 'stopped'", + createdAt: "string", +}); + +export const SessionSummary = type({ + id: "string", + tenantId: "string", + tenantName: "string", + definitionId: "string", + definitionName: "string", + status: "'idle' | 'ending' | 'ended'", + createdAt: "string", + "lastActivityAt?": "string | null", +}); + +export const ApprovalSummary = type({ + id: "string", + tenantId: "string", + tenantName: "string", + definitionId: "string", + definitionName: "string", + sessionId: type("string").describe( + "Internal FK to the session channel. The run ID can be resolved via the session relationship.", + ), + resource: "string", + action: "string", + createdAt: "string", +}); diff --git a/vendor/intx-types/src/message-id.test.ts b/vendor/intx-types/src/message-id.test.ts new file mode 100644 index 000000000..10a574eb6 --- /dev/null +++ b/vendor/intx-types/src/message-id.test.ts @@ -0,0 +1,48 @@ +import { describe, test, expect } from "bun:test"; + +import { deriveMessageId, parseMessageIdHeader } from "./message-id"; + +const encoder = new TextEncoder(); + +describe("deriveMessageId", () => { + test("returns the Message-ID header value verbatim when present", async () => { + const raw = encoder.encode( + [ + "From: a@example.com", + "To: b@example.com", + "Message-ID: ", + "", + "body", + ].join("\r\n"), + ); + expect(await deriveMessageId(raw)).toBe(""); + }); + + test("is case-insensitive on the header name", async () => { + const raw = encoder.encode( + ["message-id: ", "", "body"].join("\n"), + ); + expect(await deriveMessageId(raw)).toBe(""); + }); + + test("tolerates a lone-LF header boundary", async () => { + const raw = encoder.encode( + ["Message-ID: ", "", "body"].join("\n"), + ); + expect(await deriveMessageId(raw)).toBe(""); + }); + + test("falls back to a sha256 hex digest with no Message-ID header", async () => { + const raw = encoder.encode("From: a@example.com\r\n\r\nbody"); + const derived = await deriveMessageId(raw); + // 32-byte sha256 rendered as lowercase hex. + expect(derived).toMatch(/^[0-9a-f]{64}$/); + // Deterministic for the same bytes. + expect(await deriveMessageId(raw)).toBe(derived); + }); + + test("parseMessageIdHeader returns null when absent", () => { + const raw = encoder.encode("From: a@example.com\r\n\r\nbody"); + expect(parseMessageIdHeader(raw)).toBeNull(); + }); +}); diff --git a/vendor/intx-types/src/message-id.ts b/vendor/intx-types/src/message-id.ts new file mode 100644 index 000000000..9e6436768 --- /dev/null +++ b/vendor/intx-types/src/message-id.ts @@ -0,0 +1,82 @@ +// Canonical Message-ID derivation for a raw RFC 2822 message. +// +// This id identifies the MESSAGE, not the run it triggers. It is the +// claim-check dedup key the inbox pipeline keys on (the same bytes +// delivered twice consume once), and it must be derived identically +// wherever a message is fingerprinted, or a redelivery would be treated +// as a fresh message. This module is the single source of truth those +// call sites import. +// +// A workflow run's id is NOT this value -- every run of a deployment +// shares the deployment's mail address as its stable runId (see +// `deriveWorkflowRunId`). The two ids are distinct: this one is +// per-message, the runId is per-deployment. +// +// The identifier is the `Message-ID` header value when the message +// carries one, and a sha256 of the raw bytes otherwise -- so a message +// from a non-RFC 2822 transport still receives a deterministic id. + +import { hexEncode } from "./hex"; + +/** + * Derive the canonical Message-ID for a raw message. Returns the parsed + * `Message-ID` header when present, else the hex-encoded sha256 of the + * raw bytes. + */ +export async function deriveMessageId(rawMessage: Uint8Array): Promise { + const messageIdFromHeader = parseMessageIdHeader(rawMessage); + if (messageIdFromHeader !== null) { + return messageIdFromHeader; + } + const digest = await crypto.subtle.digest( + "SHA-256", + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ArrayBuffer-backed at the call site; Web Crypto's BufferSource type rejects Uint8Array under TS 5.9 (microsoft/TypeScript#62240) + rawMessage as Uint8Array, + ); + return hexEncode(new Uint8Array(digest)); +} + +/** + * Parse the `Message-ID` header value from a raw message, or `null` when + * the message carries no such header. + * + * The parser walks the message until the headers/body separator + * (`CRLF CRLF` per RFC 2822 §2.1, with the lone-`LF` variant tolerated to + * match common in-memory senders). Header-field unfolding follows RFC + * 2822 §2.2.3: a continuation line begins with whitespace and appends to + * the prior line. Header-name comparison is case-insensitive per RFC 2822 + * §1.2.2. + */ +export function parseMessageIdHeader(rawMessage: Uint8Array): string | null { + const text = new TextDecoder("utf-8", { fatal: false }).decode(rawMessage); + // Headers end at the first blank line. RFC 2822 mandates `CRLF CRLF` + // but tolerate `LF LF` for callers that normalize line endings. + let headerSection = text; + const crlfBoundary = text.indexOf("\r\n\r\n"); + const lfBoundary = text.indexOf("\n\n"); + if (crlfBoundary >= 0 && (lfBoundary < 0 || crlfBoundary < lfBoundary)) { + headerSection = text.slice(0, crlfBoundary); + } else if (lfBoundary >= 0) { + headerSection = text.slice(0, lfBoundary); + } + // Unfold continuation lines (a line starting with WSP belongs to + // the prior header field). + const lines = headerSection.split(/\r?\n/); + const unfolded: string[] = []; + for (const line of lines) { + if (line.length > 0 && (line[0] === " " || line[0] === "\t")) { + if (unfolded.length === 0) continue; + unfolded[unfolded.length - 1] += " " + line.trim(); + continue; + } + unfolded.push(line); + } + for (const line of unfolded) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const name = line.slice(0, colon).trim().toLowerCase(); + if (name !== "message-id") continue; + return line.slice(colon + 1).trim(); + } + return null; +} diff --git a/vendor/intx-types/src/models.ts b/vendor/intx-types/src/models.ts new file mode 100644 index 000000000..3262753fc --- /dev/null +++ b/vendor/intx-types/src/models.ts @@ -0,0 +1,39 @@ +import { type } from "arktype"; + +import { Capability } from "./capabilities"; +import { ModelProviderPlugin, PricingRowResponse } from "./catalog"; + +export const ModelOfferingInfo = type({ + offeringId: type("string").describe( + "Catalog primary key of the model-provider offering this entry describes.", + ), + providerId: "string", + providerName: type("string").describe( + "The model-provider's catalog name, as shown to operators.", + ), + plugin: ModelProviderPlugin, + priority: type("number").describe( + "Source-resolution ordering hint for this offering; lower values are preferred first.", + ), + deploymentTags: "string[]", + capabilities: Capability.array().describe( + "Curated capability tags this provider advertises for this model.", + ), + pricing: PricingRowResponse.array().describe( + "The active price per currency for this offering: for each currency, the latest pricing row in effect at the time of the discovery request.", + ), +}); +export type ModelOfferingInfo = typeof ModelOfferingInfo.infer; + +export const ModelInfo = type({ + id: "string", + canonicalName: type("string").describe( + "The model's tenant-unique canonical name, matched against an agent's model requirements.", + ), + "displayName?": "string | null", + "description?": "string | null", + offerings: ModelOfferingInfo.array().describe( + "One entry per provider that offers this model in the tenant's resolved catalog, ordered by resolution priority.", + ), +}); +export type ModelInfo = typeof ModelInfo.infer; diff --git a/vendor/intx-types/src/oauth-clients.ts b/vendor/intx-types/src/oauth-clients.ts new file mode 100644 index 000000000..38ddc6344 --- /dev/null +++ b/vendor/intx-types/src/oauth-clients.ts @@ -0,0 +1,47 @@ +import { type } from "arktype"; + +const redirectUrisDescription = + "Allowed OAuth redirect URIs for this client. The authorization callback must match one of these."; + +const defaultScopesDescription = + "Scopes requested by default when initiating an authorization flow with this client."; + +const oauthClientMetadataDescription = + "Free-form client-specific configuration not covered by the typed fields. Not interpreted by the hub."; + +export const CreateOAuthClient = type({ + providerId: "string", + name: "string", + clientId: "string", + clientSecret: "string", + "redirectUris?": type("string[]").describe(redirectUrisDescription), + "defaultScopes?": type("string[]").describe(defaultScopesDescription), + "metadata?": type("Record").describe( + oauthClientMetadataDescription, + ), +}); + +export const UpdateOAuthClient = type({ + "name?": "string", + "clientId?": "string", + "clientSecret?": "string", + "redirectUris?": type("string[] | null").describe(redirectUrisDescription), + "defaultScopes?": type("string[] | null").describe(defaultScopesDescription), + "metadata?": type("Record | null").describe( + oauthClientMetadataDescription, + ), +}); + +export const OAuthClientResponse = type({ + id: "string", + tenantId: "string", + providerId: "string", + name: "string", + "redirectUris?": type("string[] | null").describe(redirectUrisDescription), + "defaultScopes?": type("string[] | null").describe(defaultScopesDescription), + "metadata?": type("Record | null").describe( + oauthClientMetadataDescription, + ), + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/observability.ts b/vendor/intx-types/src/observability.ts new file mode 100644 index 000000000..61027ba16 --- /dev/null +++ b/vendor/intx-types/src/observability.ts @@ -0,0 +1,64 @@ +import { type } from "arktype"; + +export const LogEntry = type({ + timestamp: "string", + level: "'debug' | 'info' | 'warn' | 'error'", + message: "string", + "metadata?": "Record | null", +}); + +export const LogQuery = type({ + "level?": "'debug' | 'info' | 'warn' | 'error'", + "startTime?": "string", + "endTime?": "string", +}); + +export const MetricsResponse = type({ + agentId: "string", + "messageCount?": "number", + "tokenUsage?": { + "input?": "number", + "output?": "number", + "total?": "number", + }, + "cost?": "string", + "avgLatencyMs?": "number", + "errorRate?": "number", +}); + +export const TraceQuery = type({ + "agentId?": "string", + "sessionId?": "string", + "traceId?": "string", + "startTime?": "string", + "endTime?": "string", +}); + +export const SpanResponse = type({ + spanId: "string", + traceId: "string", + "parentSpanId?": "string | null", + name: "string", + "agentId?": "string | null", + startTime: "string", + "endTime?": "string | null", + "durationMs?": "number | null", + "status?": "'ok' | 'error'", + "attributes?": "Record | null", +}); + +export const TraceResponse = type({ + traceId: "string", + spans: type({ + spanId: "string", + traceId: "string", + "parentSpanId?": "string | null", + name: "string", + "agentId?": "string | null", + startTime: "string", + "endTime?": "string | null", + "durationMs?": "number | null", + "status?": "'ok' | 'error'", + "attributes?": "Record | null", + }).array(), +}); diff --git a/vendor/intx-types/src/offerings.ts b/vendor/intx-types/src/offerings.ts new file mode 100644 index 000000000..2bf7ac6aa --- /dev/null +++ b/vendor/intx-types/src/offerings.ts @@ -0,0 +1,67 @@ +import { type } from "arktype"; + +export const CreateOffering = type({ + agentId: "string", + name: "string", + "description?": "string", + "pricing?": { + "base?": { + amount: "string", + currency: "string", + }, + "methods?": "string[]", + "negotiable?": "boolean", + "bounds?": { + "min?": "string", + "max?": "string", + }, + }, + "schema?": "Record", +}); + +export const UpdateOffering = type({ + "name?": "string", + "description?": "string", + "pricing?": { + "base?": { + amount: "string", + currency: "string", + }, + "methods?": "string[]", + "negotiable?": "boolean", + "bounds?": { + "min?": "string", + "max?": "string", + }, + }, + "schema?": "Record", +}); + +export const OfferingSearch = type({ + "name?": "string", + "minPrice?": "string", + "maxPrice?": "string", + "paymentMethod?": "string", +}); + +export const OfferingDetail = type({ + id: "string", + agentId: "string", + agentName: "string", + tenantId: "string", + name: "string", + "description?": "string | null", + "pricing?": { + "base?": { + amount: "string", + currency: "string", + }, + "methods?": "string[]", + "negotiable?": "boolean", + "bounds?": { + "min?": "string", + "max?": "string", + }, + }, + "schema?": "Record | null", +}); diff --git a/vendor/intx-types/src/package-json.ts b/vendor/intx-types/src/package-json.ts new file mode 100644 index 000000000..87c9f5d09 --- /dev/null +++ b/vendor/intx-types/src/package-json.ts @@ -0,0 +1,25 @@ +// Schema for the subset of `package.json` fields the asset substrate +// and tool-package builders read. +// +// Promoted here so the package-registry kind handler (in +// `@intx/hub-sessions`) and the workspace builtin-packing script +// (`bin/build-builtins.ts`) share one definition: the asset +// substrate's validation of an uploaded tarball must match the field +// set the build path emits, otherwise a freshly-packed builtin would +// be rejected for shape reasons the build did not anticipate. + +import { type } from "arktype"; + +/** + * Required fields plus the `interchange.tools` extension used to + * identify tool packages. `onUndeclaredKey("ignore")` lets the + * arbitrary upstream npm fields pass through without listing them. + */ +export const PackageJSON = type({ + name: "string", + version: "string", + "interchange?": type({ + "tools?": "string", + }).onUndeclaredKey("ignore"), +}).onUndeclaredKey("ignore"); +export type PackageJSON = typeof PackageJSON.infer; diff --git a/vendor/intx-types/src/principals.ts b/vendor/intx-types/src/principals.ts new file mode 100644 index 000000000..d7c456042 --- /dev/null +++ b/vendor/intx-types/src/principals.ts @@ -0,0 +1,57 @@ +import { type } from "arktype"; + +export const principalKinds = ["user", "agent", "workflow"] as const; +export type PrincipalKind = (typeof principalKinds)[number]; + +export const principalStatuses = [ + "active", + "suspended", + "invited", + "deactivated", +] as const; +export type PrincipalStatus = (typeof principalStatuses)[number]; + +export const updatablePrincipalStatuses = [ + "active", + "suspended", + "deactivated", +] as const; +export type UpdatablePrincipalStatus = + (typeof updatablePrincipalStatuses)[number]; + +const Kind = type.enumerated(...principalKinds); +const Status = type.enumerated(...principalStatuses); +const UpdatableStatus = type.enumerated(...updatablePrincipalStatuses); + +export const PrincipalResponse = type({ + id: "string", + tenantId: "string", + kind: Kind.describe( + "Whether this principal represents a `user` (a human account), an `agent`, or a `workflow` (a workflow deployment).", + ), + refId: type("string").describe( + "Identifier of the underlying entity this principal stands for: the auth user id when `kind` is `user`, the agent id when `kind` is `agent`, or the run id when `kind` is `workflow`. Unique per tenant and kind.", + ), + displayName: "string", + "email?": "string", + status: Status.describe( + "Account state of the principal: `active`, `suspended`, `invited` (membership pending acceptance), or `deactivated`.", + ), + roles: type({ + id: "string", + name: "string", + }).array(), + createdAt: "string", + updatedAt: "string", +}); + +export const UpdatePrincipal = type({ + status: UpdatableStatus.describe( + "New account state for the principal. Only `active`, `suspended`, and `deactivated` are settable; `invited` is reached only through the invitation flow.", + ), +}); + +export const InviteMember = type({ + email: "string", + "roleId?": "string", +}); diff --git a/vendor/intx-types/src/providers.ts b/vendor/intx-types/src/providers.ts new file mode 100644 index 000000000..a02d3d01f --- /dev/null +++ b/vendor/intx-types/src/providers.ts @@ -0,0 +1,50 @@ +import { type } from "arktype"; + +const pluginDescription = + "Identifier of the integration this provider drives (for example the inference backend). Used to dispatch to the matching plugin and as the prefix when forming fully-qualified model ids (`plugin:model`)."; + +const providerScopesDescription = + "OAuth scopes associated with this provider integration."; + +const providerMetadataDescription = + "Free-form provider-specific configuration not covered by the typed fields. Not interpreted by the hub."; + +export const CreateProvider = type({ + name: "string", + plugin: type("string").describe(pluginDescription), + "authorizationUrl?": "string", + "tokenUrl?": "string", + "userInfoUrl?": "string", + "scopes?": type("string[]").describe(providerScopesDescription), + "metadata?": type("Record").describe( + providerMetadataDescription, + ), +}); + +export const UpdateProvider = type({ + "name?": "string", + "plugin?": type("string").describe(pluginDescription), + "authorizationUrl?": "string | null", + "tokenUrl?": "string | null", + "userInfoUrl?": "string | null", + "scopes?": type("string[] | null").describe(providerScopesDescription), + "metadata?": type("Record | null").describe( + providerMetadataDescription, + ), +}); + +export const ProviderResponse = type({ + id: "string", + tenantId: "string", + name: "string", + plugin: type("string").describe(pluginDescription), + "authorizationUrl?": "string | null", + "tokenUrl?": "string | null", + "userInfoUrl?": "string | null", + "scopes?": type("string[] | null").describe(providerScopesDescription), + "metadata?": type("Record | null").describe( + providerMetadataDescription, + ), + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/roles.ts b/vendor/intx-types/src/roles.ts new file mode 100644 index 000000000..201e95208 --- /dev/null +++ b/vendor/intx-types/src/roles.ts @@ -0,0 +1,21 @@ +import { type } from "arktype"; + +export const CreateRole = type({ + name: "string", + "description?": "string", +}); + +export const UpdateRole = type({ + "name?": "string", + "description?": "string", +}); + +export const RoleResponse = type({ + id: "string", + tenantId: "string", + name: "string", + "description?": "string | null", + isSystem: "boolean", + createdAt: "string", + updatedAt: "string", +}); diff --git a/vendor/intx-types/src/runtime-capabilities.test.ts b/vendor/intx-types/src/runtime-capabilities.test.ts new file mode 100644 index 000000000..c0d2f0820 --- /dev/null +++ b/vendor/intx-types/src/runtime-capabilities.test.ts @@ -0,0 +1,61 @@ +import { describe, test, expect } from "bun:test"; +import type { MessageTransport } from "./runtime"; +import { + createRuntimeCapabilities, + type RuntimeCapabilityMap, +} from "./runtime-capabilities"; + +// A minimal stand-in for MessageTransport. The resolver does not invoke any +// transport methods — these tests assert handle-passing semantics, not +// transport behavior. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only stand-in; resolver never calls these methods +const stubTransport = {} as unknown as MessageTransport; + +describe("createRuntimeCapabilities", () => { + test("resolve returns the same reference the host provided", () => { + const capabilities = createRuntimeCapabilities({ + "mail.transport": stubTransport, + }); + + expect(capabilities.resolve("mail.transport")).toBe(stubTransport); + }); + + test("resolve throws naming the missing key and identifies it as not provided", () => { + const capabilities = createRuntimeCapabilities({}); + + expect(() => capabilities.resolve("mail.transport")).toThrow( + /"mail\.transport".*not provided by the host/, + ); + }); + + test("resolve throws distinctly when the host wires undefined to a non-nullable key", () => { + // The host explicitly passed the key but set its value to undefined. + // exactOptionalPropertyTypes prevents this construction through the + // typed entry point, so the test casts to exercise the runtime + // defensive check that catches a host that subverts the type system + // (e.g. through an `as any` or an external JSON source that wasn't + // validated). The resolver must surface this as a distinct error + // from "key not provided" so the host can tell the two failure + // modes apart. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- exercising the runtime guard that catches type-system subversion + const subverted = { + "mail.transport": undefined, + } as unknown as Partial; + const capabilities = createRuntimeCapabilities(subverted); + + expect(() => capabilities.resolve("mail.transport")).toThrow( + /"mail\.transport".*provided as undefined/, + ); + }); + + test("mutating the input map after construction does not affect resolution", () => { + const values: Partial = { + "mail.transport": stubTransport, + }; + const capabilities = createRuntimeCapabilities(values); + + delete values["mail.transport"]; + + expect(capabilities.resolve("mail.transport")).toBe(stubTransport); + }); +}); diff --git a/vendor/intx-types/src/runtime-capabilities.ts b/vendor/intx-types/src/runtime-capabilities.ts new file mode 100644 index 000000000..2202268fb --- /dev/null +++ b/vendor/intx-types/src/runtime-capabilities.ts @@ -0,0 +1,85 @@ +// Typed registry of host-provided capabilities that tool packages request at +// handler-init. The host (sidecar harness, or an alternate runtime) builds a +// RuntimeCapabilities instance and hands it to each tool package's factory; +// the package calls `resolve` to obtain typed handles to host services. +// +// The map is the extension point: new capabilities are added by extending +// RuntimeCapabilityMap inside this file. TypeScript permits module +// augmentation of the interface from any consumer, but augmentation from +// outside @intx/types is not the supported extension path; contribute +// keys here so every host sees the same canonical map. + +import type { MessageTransport } from "./runtime"; + +/** + * Registry of capability keys to the value types they resolve to. Keys are + * dotted strings scoped by subsystem (e.g. `mail.transport`). + * + * Adding a capability: extend this interface with the new key and its value + * type, then have a host populate it when constructing a + * `RuntimeCapabilities`. + */ +export interface RuntimeCapabilityMap { + /** + * The bound agent's message transport — the SMTP/IMAP-equivalent handle + * for sending and receiving mail. + */ + "mail.transport": MessageTransport; +} + +export type RuntimeCapabilityKey = keyof RuntimeCapabilityMap; + +/** + * Host-provided capability registry. Tool packages receive an instance at + * construction; `resolve` is intended to be called once per key at + * handler-init, with the returned handle held for the deploy lifetime. + * `resolve` throws naming the key when the host did not provide a value + * for it. + */ +export interface RuntimeCapabilities { + resolve(key: K): RuntimeCapabilityMap[K]; +} + +/** + * Build a resolver from a partial map of capability values. The map is + * snapshotted at construction — later mutation of the input is not visible + * to `resolve`. Keys absent from the snapshot throw at resolve-time with a + * message naming the key. + * + * Use this from any host (harness, test harness, alternate runtime) that + * wants the standard resolver semantics without re-implementing the + * throw-on-missing plumbing. + */ +export function createRuntimeCapabilities( + values: Partial, +): RuntimeCapabilities { + // Snapshot the input. The resolver's lifecycle contract is "resolved + // once at handler-init, held for the deploy lifetime" — later mutation + // of the input map by the host must not be observable here. + const snapshot: Partial = { ...values }; + + return { + resolve(key: K): RuntimeCapabilityMap[K] { + // Object.hasOwn distinguishes "host did not provide" from "host + // provided undefined". Both are distinct failures the host + // should hear about separately. No capability in + // RuntimeCapabilityMap currently resolves to undefined, so the + // second check is a defensive guard against a host accidentally + // wiring an undefined value to a non-nullable capability slot; + // adding a nullable capability in the future means revisiting + // this branch. + if (!Object.hasOwn(snapshot, key)) { + throw new Error( + `Runtime capability "${String(key)}" was requested but not provided by the host`, + ); + } + const value = snapshot[key]; + if (value === undefined) { + throw new Error( + `Runtime capability "${String(key)}" was provided as undefined; no current capability resolves to undefined`, + ); + } + return value; + }, + }; +} diff --git a/vendor/intx-types/src/runtime.test.ts b/vendor/intx-types/src/runtime.test.ts new file mode 100644 index 000000000..c26ce7e28 --- /dev/null +++ b/vendor/intx-types/src/runtime.test.ts @@ -0,0 +1,1279 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; +import { + ApprovalSnapshot, + APPROVAL_SNAPSHOT_MAX_BYTES, + BoundedApprovalSnapshot, + ContentBlock, + formatSafetyRatingText, + InferenceEvent, + MediaSource, + TransformRecord, + type ContextTransform, + type ToolResultTransform, + type Compactor, + type ReactorAction, + type ReactorCapabilities, + type BlobReader, + type BlobSource, + createBlobReader, + parseToolOutputURI, +} from "./runtime"; + +// --------------------------------------------------------------------------- +// 1. TransformRecord validator +// --------------------------------------------------------------------------- + +describe("TransformRecord validator", () => { + test("accepts a well-formed record", () => { + const result = TransformRecord({ + strategy: "size-cap", + version: "1", + parameters: { maxChars: 10_000 }, + reason: "exceeded-cap", + decisions: { callId: "abc", originalBytes: 50_000, kept: 10_000 }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a record with empty parameter and decision maps", () => { + const result = TransformRecord({ + strategy: "noop", + version: "1", + parameters: {}, + reason: "no-op", + decisions: {}, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects records missing required fields", () => { + const missingStrategy = TransformRecord({ + version: "1", + parameters: {}, + reason: "x", + decisions: {}, + }); + expect(missingStrategy instanceof type.errors).toBe(true); + + const missingVersion = TransformRecord({ + strategy: "size-cap", + parameters: {}, + reason: "x", + decisions: {}, + }); + expect(missingVersion instanceof type.errors).toBe(true); + + const missingParameters = TransformRecord({ + strategy: "size-cap", + version: "1", + reason: "x", + decisions: {}, + }); + expect(missingParameters instanceof type.errors).toBe(true); + + const missingReason = TransformRecord({ + strategy: "size-cap", + version: "1", + parameters: {}, + decisions: {}, + }); + expect(missingReason instanceof type.errors).toBe(true); + + const missingDecisions = TransformRecord({ + strategy: "size-cap", + version: "1", + parameters: {}, + reason: "x", + }); + expect(missingDecisions instanceof type.errors).toBe(true); + }); + + test("rejects records with wrong-typed fields", () => { + const wrongStrategy = TransformRecord({ + strategy: 123, + version: "1", + parameters: {}, + reason: "x", + decisions: {}, + }); + expect(wrongStrategy instanceof type.errors).toBe(true); + + const wrongParameters = TransformRecord({ + strategy: "size-cap", + version: "1", + parameters: "not-a-record", + reason: "x", + decisions: {}, + }); + expect(wrongParameters instanceof type.errors).toBe(true); + + const wrongReason = TransformRecord({ + strategy: "size-cap", + version: "1", + parameters: {}, + reason: 42, + decisions: {}, + }); + expect(wrongReason instanceof type.errors).toBe(true); + + const wrongDecisions = TransformRecord({ + strategy: "size-cap", + version: "1", + parameters: {}, + reason: "x", + decisions: "not-a-record", + }); + expect(wrongDecisions instanceof type.errors).toBe(true); + }); + + test("rejects non-object inputs", () => { + expect(TransformRecord(null) instanceof type.errors).toBe(true); + expect(TransformRecord("string") instanceof type.errors).toBe(true); + expect(TransformRecord(42) instanceof type.errors).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 2. compact action variant +// --------------------------------------------------------------------------- + +describe("compact action", () => { + test("ReactorCapabilities.compact constructs the expected action shape", () => { + const caps: Pick = { + compact(compactor: string, reason: string): ReactorAction { + return { type: "compact", compactor, reason }; + }, + }; + + const action = caps.compact("summarize-tail", "capacity"); + + expect(action.type).toBe("compact"); + if (action.type !== "compact") throw new Error("unreachable"); + expect(action.compactor).toBe("summarize-tail"); + expect(action.reason).toBe("capacity"); + }); + + test("compact action is structurally compatible with the ReactorAction union", () => { + const action: ReactorAction = { + type: "compact", + compactor: "summarize-tail", + reason: "overflow-recovery", + }; + + if (action.type === "compact") { + expect(action.compactor).toBe("summarize-tail"); + expect(action.reason).toBe("overflow-recovery"); + } else { + throw new Error("expected compact narrowing"); + } + }); +}); + +// --------------------------------------------------------------------------- +// 3. Type-relationship sanity +// --------------------------------------------------------------------------- + +describe("ContextTransform vs ToolResultTransform assignability", () => { + test("a ContextTransform is not assignable to a ToolResultTransform", () => { + // A ContextTransform operates on ConversationTurn[]; a ToolResultTransform + // operates on { call, result }. Their input types are disjoint, so a + // value typed as one cannot satisfy the other. The // @ts-expect-error + // below proves the compiler enforces this — if the assignment ever + // becomes valid (e.g. the input/output type parameters are widened), + // the tsc check will fail loudly here. + const ctxTransform: ContextTransform = { + name: "ctx", + version: "1", + async apply(turns, _ctx) { + return { + output: turns, + record: { + strategy: "ctx", + version: "1", + parameters: {}, + reason: "noop", + decisions: {}, + }, + }; + }, + }; + + // @ts-expect-error -- ContextTransform input is ConversationTurn[]; ToolResultTransform input is { call, result } + ctxTransform satisfies ToolResultTransform; + + expect(ctxTransform.name).toBe("ctx"); + }); + + test("a ToolResultTransform is not assignable to a ContextTransform", () => { + const trTransform: ToolResultTransform = { + name: "tr", + version: "1", + async apply(input, _ctx) { + return { + output: input.result, + record: { + strategy: "tr", + version: "1", + parameters: {}, + reason: "noop", + decisions: {}, + }, + }; + }, + }; + + // @ts-expect-error -- ToolResultTransform input is { call, result }; ContextTransform input is ConversationTurn[] + trTransform satisfies ContextTransform; + + expect(trTransform.name).toBe("tr"); + }); + + test("Compactor and ContextTransform share input/output types", () => { + // Compactor and ContextTransform are both + // ContextStrategy, so any + // value typed as one is also typed as the other. The reactor + // enforces their role distinction at the registration layer, not + // through the type system. + const compactor: Compactor = { + name: "summarize-tail", + version: "1", + async apply(turns, _ctx) { + return { + output: turns, + record: { + strategy: "summarize-tail", + version: "1", + parameters: {}, + reason: "noop", + decisions: {}, + }, + }; + }, + }; + + const asContextTransform: ContextTransform = compactor; + expect(asContextTransform.name).toBe("summarize-tail"); + }); +}); + +// --------------------------------------------------------------------------- +// 4. BlobReader URI parsing and dispatch +// --------------------------------------------------------------------------- + +describe("parseToolOutputURI", () => { + test("extracts the callId from a well-formed three-slash URI", () => { + expect(parseToolOutputURI("tool-output:///abc123")).toBe("abc123"); + }); + + test("preserves case in the callId", () => { + expect(parseToolOutputURI("tool-output:///AbC123")).toBe("AbC123"); + }); + + test("rejects the two-slash form because hostnames are lowercased", () => { + let thrown: Error | undefined; + try { + parseToolOutputURI("tool-output://abc123"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("authority must be empty"); + }); + + test("rejects a non-tool-output scheme", () => { + let thrown: Error | undefined; + try { + parseToolOutputURI("file:///abc123"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain('expected "tool-output:"'); + }); + + test("rejects extra path segments", () => { + let thrown: Error | undefined; + try { + parseToolOutputURI("tool-output:///abc/extra"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("single callId segment"); + }); + + test("rejects an empty callId", () => { + let thrown: Error | undefined; + try { + parseToolOutputURI("tool-output:///"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("missing callId"); + }); + + test("rejects a URI with a query string", () => { + let thrown: Error | undefined; + try { + parseToolOutputURI("tool-output:///abc?x=1"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("query string is not allowed"); + }); + + test("rejects a URI with a fragment", () => { + let thrown: Error | undefined; + try { + parseToolOutputURI("tool-output:///abc#x"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("fragment is not allowed"); + }); + + test("rejects a non-URI string", () => { + let thrown: Error | undefined; + try { + parseToolOutputURI("not a uri"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("invalid tool-output URI"); + }); +}); + +describe("createBlobReader", () => { + test("delegates to source.readBlob with the parsed callId", async () => { + const seen: string[] = []; + const source: BlobSource = { + async readBlob(key) { + seen.push(key); + return new TextEncoder().encode(`bytes-for-${key}`); + }, + }; + const reader: BlobReader = createBlobReader(source); + + const bytes = await reader.read("tool-output:///CallId123"); + expect(seen).toEqual(["CallId123"]); + expect(new TextDecoder().decode(bytes)).toBe("bytes-for-CallId123"); + }); + + test("propagates errors from source.readBlob", async () => { + const source: BlobSource = { + async readBlob() { + throw new Error("Blob not found for key: missing"); + }, + }; + const reader = createBlobReader(source); + + let thrown: Error | undefined; + try { + await reader.read("tool-output:///missing"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("Blob not found"); + }); + + test("throws on malformed URIs without touching the source", async () => { + let touched = false; + const source: BlobSource = { + async readBlob() { + touched = true; + return new Uint8Array(); + }, + }; + const reader = createBlobReader(source); + + let thrown: Error | undefined; + try { + await reader.read("file:///abc"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("invalid tool-output URI scheme"); + expect(touched).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// MediaSource validator and ImageBlock shape +// --------------------------------------------------------------------------- + +describe("MediaSource validator", () => { + test("accepts a well-formed base64 source", () => { + const result = MediaSource({ + kind: "base64", + mimeType: "image/png", + data: "aGVsbG8=", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a well-formed file-reference source", () => { + const result = MediaSource({ + kind: "file-reference", + mimeType: "application/pdf", + reference: "file_abc123", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a base64 source missing mimeType", () => { + const result = MediaSource({ kind: "base64", data: "aGVsbG8=" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a base64 source missing data", () => { + const result = MediaSource({ kind: "base64", mimeType: "image/png" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a file-reference source missing mimeType", () => { + const result = MediaSource({ + kind: "file-reference", + reference: "file_abc", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a file-reference source missing reference", () => { + const result = MediaSource({ + kind: "file-reference", + mimeType: "image/png", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts a well-formed url source", () => { + const result = MediaSource({ + kind: "url", + mimeType: "image/png", + url: "https://example.com/img.png", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a url source missing mimeType", () => { + const result = MediaSource({ + kind: "url", + url: "https://example.com/img.png", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a url source missing url", () => { + const result = MediaSource({ + kind: "url", + mimeType: "image/png", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an unknown kind", () => { + const result = MediaSource({ + kind: "data-uri", + mimeType: "image/png", + data: "aGVsbG8=", + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("ImageBlock shape on ContentBlock", () => { + test("accepts an image block with a base64 source", () => { + const result = ContentBlock({ + type: "image", + source: { kind: "base64", mimeType: "image/png", data: "aGVsbG8=" }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an image block with a file-reference source", () => { + const result = ContentBlock({ + type: "image", + source: { + kind: "file-reference", + mimeType: "application/pdf", + reference: "file_abc", + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects the legacy flat shape (mimeType/data without source)", () => { + const result = ContentBlock({ + type: "image", + mimeType: "image/png", + data: "aGVsbG8=", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an image block without a source", () => { + const result = ContentBlock({ type: "image" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts an image inside a tool_result content array", () => { + const result = ContentBlock({ + type: "tool_result", + callId: "call_abc", + content: [ + { type: "text", text: "the screenshot:" }, + { + type: "image", + source: { kind: "base64", mimeType: "image/png", data: "aGVsbG8=" }, + }, + ], + }); + expect(result instanceof type.errors).toBe(false); + }); +}); + +describe("AudioBlock / VideoBlock / DocumentBlock variants on ContentBlock", () => { + test.each([ + ["audio", "audio/wav", "UklGRg=="], + ["video", "video/mp4", "AAAAGGZ0eXA="], + ["document", "application/pdf", "JVBERi0="], + ])("accepts a %s block with a base64 source", (kind, mimeType, data) => { + const result = ContentBlock({ + type: kind, + source: { kind: "base64", mimeType, data }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test.each([ + ["audio", "audio/wav"], + ["video", "video/mp4"], + ["document", "application/pdf"], + ])("accepts a %s block with a file-reference source", (kind, mimeType) => { + const result = ContentBlock({ + type: kind, + source: { kind: "file-reference", mimeType, reference: "file_abc" }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test.each(["audio", "video", "document"])( + "rejects a %s block without a source", + (kind) => { + const result = ContentBlock({ type: kind }); + expect(result instanceof type.errors).toBe(true); + }, + ); + + test.each(["audio", "video", "document"])( + "rejects a %s block with the legacy flat shape (mimeType/data, no source)", + (kind) => { + const result = ContentBlock({ + type: kind, + mimeType: "application/octet-stream", + data: "aGVsbG8=", + }); + expect(result instanceof type.errors).toBe(true); + }, + ); + + test.each([ + ["audio", "audio/wav", "UklGRg=="], + ["video", "video/mp4", "AAAAGGZ0eXA="], + ["document", "application/pdf", "JVBERi0="], + ])( + "accepts a %s block (base64) inside a tool_result content array", + (kind, mimeType, data) => { + const result = ContentBlock({ + type: "tool_result", + callId: "call_xyz", + content: [ + { type: "text", text: "the payload:" }, + { type: kind, source: { kind: "base64", mimeType, data } }, + ], + }); + expect(result instanceof type.errors).toBe(false); + }, + ); + + test.each([ + ["audio", "audio/wav", "file_audio"], + ["video", "video/mp4", "file_video"], + ["document", "application/pdf", "file_doc"], + ])( + "accepts a %s block (file-reference) inside a tool_result content array", + (kind, mimeType, reference) => { + const result = ContentBlock({ + type: "tool_result", + callId: "call_xyz", + content: [ + { type: "text", text: "the payload:" }, + { + type: kind, + source: { kind: "file-reference", mimeType, reference }, + }, + ], + }); + expect(result instanceof type.errors).toBe(false); + }, + ); +}); + +describe("CitationBlock", () => { + test("accepts a minimal citation with a uri source", () => { + const result = ContentBlock({ + type: "citation", + citedText: "the answer is 42", + source: { uri: "https://example.com/article", title: "Article" }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a citation with a documentRef source", () => { + const result = ContentBlock({ + type: "citation", + citedText: "per the report", + source: { documentRef: { index: 0 }, title: "Q3.pdf" }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a citation with both location and textOffset", () => { + const result = ContentBlock({ + type: "citation", + citedText: "Martinis.", + source: { uri: "https://example.com/" }, + location: { kind: "char", start: 100, end: 109 }, + textOffset: { start: 99, end: 108 }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test.each(["page", "char", "content-block"] as const)( + "accepts a citation with location kind=%s", + (kind) => { + const result = ContentBlock({ + type: "citation", + citedText: "x", + source: { uri: "https://example.com/" }, + location: { kind, start: 1, end: 2 }, + }); + expect(result instanceof type.errors).toBe(false); + }, + ); + + test("rejects a citation with an unknown location kind", () => { + const result = ContentBlock({ + type: "citation", + citedText: "x", + source: { uri: "https://example.com/" }, + location: { kind: "span", start: 1, end: 2 }, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a citation missing citedText", () => { + const result = ContentBlock({ + type: "citation", + source: { uri: "https://example.com/" }, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a citation missing source", () => { + const result = ContentBlock({ type: "citation", citedText: "x" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a citation block inside a tool_result content array", () => { + // tool_result.content is deliberately narrow; citations annotate + // model output, not tool output. + const result = ContentBlock({ + type: "tool_result", + callId: "call_abc", + content: [ + { + type: "citation", + citedText: "x", + source: { uri: "https://example.com/" }, + }, + ], + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("inference.citation event", () => { + test("accepts an inference.citation event wrapping a CitationBlock", () => { + const result = InferenceEvent({ + type: "inference.citation", + seq: 7, + data: { + citation: { + type: "citation", + citedText: "the answer is 42", + source: { uri: "https://example.com/" }, + location: { kind: "char", start: 0, end: 16 }, + textOffset: { start: 0, end: 16 }, + }, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an inference.citation event missing the citation payload", () => { + const result = InferenceEvent({ + type: "inference.citation", + seq: 7, + data: {}, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an inference.citation event with a malformed citation", () => { + const result = InferenceEvent({ + type: "inference.citation", + seq: 7, + data: { citation: { type: "citation", citedText: "x" } }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("SafetyRatingBlock", () => { + // Captured Gemini shape (2026-07-28): promptFeedback.blockReason only. + test("accepts a block with the captured PROHIBITED_CONTENT reason", () => { + const result = ContentBlock({ + type: "safety_rating", + blockReason: "PROHIBITED_CONTENT", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a safety_rating block missing blockReason", () => { + const result = ContentBlock({ type: "safety_rating" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an empty blockReason", () => { + const result = ContentBlock({ + type: "safety_rating", + blockReason: "", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a safety_rating block inside a tool_result content array", () => { + const result = ContentBlock({ + type: "tool_result", + callId: "call_abc", + content: [{ type: "safety_rating", blockReason: "PROHIBITED_CONTENT" }], + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("formatSafetyRatingText owns the display string", () => { + expect( + formatSafetyRatingText({ + type: "safety_rating", + blockReason: "PROHIBITED_CONTENT", + }), + ).toBe("Request blocked: PROHIBITED_CONTENT"); + }); +}); + +describe("inference.safety_rating event", () => { + test("accepts an inference.safety_rating event wrapping a SafetyRatingBlock", () => { + const result = InferenceEvent({ + type: "inference.safety_rating", + seq: 3, + data: { + safetyRating: { + type: "safety_rating", + blockReason: "PROHIBITED_CONTENT", + }, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an inference.safety_rating event missing the payload", () => { + const result = InferenceEvent({ + type: "inference.safety_rating", + seq: 3, + data: {}, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("CodeExecutionRequestBlock and CodeExecutionResultBlock", () => { + test("accepts a minimal code_execution_request", () => { + const result = ContentBlock({ + type: "code_execution_request", + id: "srvtoolu_01", + code: "print('hi')", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a code_execution_request with a language hint", () => { + const result = ContentBlock({ + type: "code_execution_request", + id: "srvtoolu_01", + code: "print('hi')", + language: "python", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a code_execution_request missing id", () => { + const result = ContentBlock({ + type: "code_execution_request", + code: "print('hi')", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a code_execution_request missing code", () => { + const result = ContentBlock({ + type: "code_execution_request", + id: "srvtoolu_01", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test.each(["ok", "error", "aborted", "timeout"] as const)( + "accepts a code_execution_result with status=%s", + (status) => { + const result = ContentBlock({ + type: "code_execution_result", + requestId: "srvtoolu_01", + status, + }); + expect(result instanceof type.errors).toBe(false); + }, + ); + + test("accepts a code_execution_result with all optional fields populated", () => { + const result = ContentBlock({ + type: "code_execution_result", + requestId: "srvtoolu_01", + status: "ok", + stdout: "144\n", + stderr: "", + returnCode: 0, + providerOutcome: "OUTCOME_OK", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a code_execution_result with abortReason on an aborted run", () => { + const result = ContentBlock({ + type: "code_execution_result", + requestId: "srvtoolu_01", + status: "aborted", + abortReason: "execution time exceeded the 30s limit", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a code_execution_result missing requestId", () => { + const result = ContentBlock({ + type: "code_execution_result", + status: "ok", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a code_execution_result with an unknown status value", () => { + const result = ContentBlock({ + type: "code_execution_result", + requestId: "srvtoolu_01", + status: "unknown", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a code_execution_request or _result inside tool_result content", () => { + // tool_result.content is deliberately narrow; server-side code + // execution has a distinct lifecycle from the user-tool round-trip. + const requestInTool = ContentBlock({ + type: "tool_result", + callId: "call_abc", + content: [ + { type: "code_execution_request", id: "srvtoolu_01", code: "x" }, + ], + }); + expect(requestInTool instanceof type.errors).toBe(true); + + const resultInTool = ContentBlock({ + type: "tool_result", + callId: "call_abc", + content: [ + { + type: "code_execution_result", + requestId: "srvtoolu_01", + status: "ok", + }, + ], + }); + expect(resultInTool instanceof type.errors).toBe(true); + }); +}); + +describe("inference.code_execution.* events", () => { + test("accepts a start event wrapping a CodeExecutionRequestBlock", () => { + const result = InferenceEvent({ + type: "inference.code_execution.start", + seq: 4, + data: { + request: { + type: "code_execution_request", + id: "srvtoolu_01", + code: "print('hi')", + language: "python", + }, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a delta event with a code fragment", () => { + const result = InferenceEvent({ + type: "inference.code_execution.delta", + seq: 5, + data: { requestId: "srvtoolu_01", codeFragment: "print(" }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a result event wrapping a CodeExecutionResultBlock", () => { + const result = InferenceEvent({ + type: "inference.code_execution.result", + seq: 6, + data: { + result: { + type: "code_execution_result", + requestId: "srvtoolu_01", + status: "ok", + stdout: "hi\n", + returnCode: 0, + }, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a code_execution event with a malformed payload", () => { + const result = InferenceEvent({ + type: "inference.code_execution.start", + seq: 4, + data: { request: { type: "code_execution_request", id: "x" } }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("RedactedThinkingBlock and inference.thinking.redacted event", () => { + test("accepts a minimal redacted_thinking block", () => { + const result = ContentBlock({ + type: "redacted_thinking", + data: "EncryptedOpaqueBlobAAAA==", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a redacted_thinking block missing data", () => { + const result = ContentBlock({ type: "redacted_thinking" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts an inference.thinking.redacted event wrapping the block", () => { + const result = InferenceEvent({ + type: "inference.thinking.redacted", + seq: 3, + data: { + redactedThinking: { + type: "redacted_thinking", + data: "EncryptedOpaqueBlobAAAA==", + }, + index: 1, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an inference.thinking.redacted event with a malformed payload", () => { + const result = InferenceEvent({ + type: "inference.thinking.redacted", + seq: 3, + data: { redactedThinking: { type: "redacted_thinking" } }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("optional index on delta event variants", () => { + const partial = { text: "" }; + + const cases: { name: string; type: string; data: Record }[] = + [ + { + name: "inference.text.delta", + type: "inference.text.delta", + data: { token: "x", partial }, + }, + { + name: "inference.thinking.delta", + type: "inference.thinking.delta", + data: { token: "x", partial }, + }, + { + name: "inference.block.signature", + type: "inference.block.signature", + data: { signature: "sig_abc" }, + }, + { + name: "inference.tool_call.start", + type: "inference.tool_call.start", + data: { callId: "call_1", name: "fn", partial }, + }, + { + name: "inference.tool_call.delta", + type: "inference.tool_call.delta", + data: { callId: "call_1", argumentFragment: "{", partial }, + }, + { + name: "inference.tool_call.end", + type: "inference.tool_call.end", + data: { callId: "call_1", name: "fn", arguments: {}, partial }, + }, + ]; + + for (const c of cases) { + test(`${c.name} accepts an optional index field`, () => { + const result = InferenceEvent({ + type: c.type, + seq: 1, + data: { ...c.data, index: 3 }, + }); + expect(result instanceof type.errors).toBe(false); + }); + } + + test("two text.delta events with different indices round-trip distinctly", () => { + // Locks the per-block semantic: index 0 and index 1 are distinct + // blocks, not aliases for "same buffer." + const e0 = InferenceEvent({ + type: "inference.text.delta", + seq: 1, + data: { token: "hello", partial, index: 0 }, + }); + const e1 = InferenceEvent({ + type: "inference.text.delta", + seq: 2, + data: { token: "world", partial, index: 1 }, + }); + expect(e0 instanceof type.errors).toBe(false); + expect(e1 instanceof type.errors).toBe(false); + if (e0 instanceof type.errors || e1 instanceof type.errors) return; + if (e0.type !== "inference.text.delta") { + throw new Error("e0 narrowed incorrectly"); + } + if (e1.type !== "inference.text.delta") { + throw new Error("e1 narrowed incorrectly"); + } + expect(e0.data.index).toBe(0); + expect(e1.data.index).toBe(1); + }); + + test("rejects a malformed (non-number) index value", () => { + const result = InferenceEvent({ + type: "inference.text.delta", + seq: 1, + data: { token: "x", partial, index: "two" }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("inference.image_output event", () => { + test("accepts an event wrapping an ImageBlock with a base64 source", () => { + const result = InferenceEvent({ + type: "inference.image_output", + seq: 8, + data: { + image: { + type: "image", + source: { + kind: "base64", + mimeType: "image/png", + data: "iVBORw0KGgo=", + }, + }, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an event with an index field", () => { + const result = InferenceEvent({ + type: "inference.image_output", + seq: 8, + data: { + image: { + type: "image", + source: { + kind: "base64", + mimeType: "image/png", + data: "iVBORw0KGgo=", + }, + }, + index: 1, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an event wrapping an ImageBlock with a file-reference", () => { + const result = InferenceEvent({ + type: "inference.image_output", + seq: 8, + data: { + image: { + type: "image", + source: { + kind: "file-reference", + mimeType: "image/png", + reference: "file_generated", + }, + }, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an event missing the image payload", () => { + const result = InferenceEvent({ + type: "inference.image_output", + seq: 8, + data: {}, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an event with a malformed image block", () => { + const result = InferenceEvent({ + type: "inference.image_output", + seq: 8, + data: { + image: { + type: "image", + // Missing `source` field. + }, + }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// ApprovalSnapshot validator +// --------------------------------------------------------------------------- + +describe("ApprovalSnapshot", () => { + const validSnapshot = { + name: "delete_file", + description: "Delete a file at the given path", + inputSchema: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + arguments: { path: "/tmp/x" }, + }; + + test("accepts a well-formed snapshot", () => { + const result = ApprovalSnapshot(validSnapshot); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a snapshot missing its description", () => { + const { description: _omitted, ...withoutDescription } = validSnapshot; + const result = ApprovalSnapshot(withoutDescription); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a snapshot whose name is not a string", () => { + const result = ApprovalSnapshot({ ...validSnapshot, name: 42 }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// BoundedApprovalSnapshot cap +// --------------------------------------------------------------------------- + +describe("BoundedApprovalSnapshot", () => { + // Build a snapshot whose serialized form is exactly `targetBytes`. The + // padding is ASCII, so one character is one UTF-8 byte and one unescaped + // serialized character, letting us land on an exact byte count and pin the + // cap's boundary (a `<=` vs `<` off-by-one is the likeliest bug). + const snapshotOfSize = (targetBytes: number) => { + const base = { + name: "t", + description: "d", + inputSchema: { pad: "" }, + arguments: {}, + }; + const baseBytes = Buffer.byteLength(JSON.stringify(base), "utf8"); + base.inputSchema.pad = "a".repeat(targetBytes - baseBytes); + return base; + }; + + test("accepts a snapshot at exactly the cap", () => { + const atCap = snapshotOfSize(APPROVAL_SNAPSHOT_MAX_BYTES); + expect(Buffer.byteLength(JSON.stringify(atCap), "utf8")).toBe( + APPROVAL_SNAPSHOT_MAX_BYTES, + ); + const result = BoundedApprovalSnapshot(atCap); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a snapshot one byte over the cap", () => { + const overCap = snapshotOfSize(APPROVAL_SNAPSHOT_MAX_BYTES + 1); + expect(Buffer.byteLength(JSON.stringify(overCap), "utf8")).toBe( + APPROVAL_SNAPSHOT_MAX_BYTES + 1, + ); + const result = BoundedApprovalSnapshot(overCap); + expect(result instanceof type.errors).toBe(true); + }); + + test("caps on UTF-8 bytes, not UTF-16 code units", () => { + // A snapshot padded with a 3-byte character is under the cap counted in + // code units (String.length) but over it counted in UTF-8 bytes. The cap + // must measure bytes, so it rejects this; a `.length`-based cap would + // wrongly accept it. Guards the byte semantics against a refactor that + // drops the explicit "utf8" byte measure. + const base = { + name: "t", + description: "d", + inputSchema: { pad: "" }, + arguments: {}, + }; + const baseBytes = Buffer.byteLength(JSON.stringify(base), "utf8"); + // Each "€" is 3 UTF-8 bytes but 1 UTF-16 code unit. Choose a count that + // pushes bytes just over the cap while code units stay well under it. The + // "+ 1" keeps the byte total strictly above the cap when the remainder + // divides evenly. + const euroCount = + Math.ceil((APPROVAL_SNAPSHOT_MAX_BYTES - baseBytes) / 3) + 1; + base.inputSchema.pad = "€".repeat(euroCount); + const serialized = JSON.stringify(base); + expect(Buffer.byteLength(serialized, "utf8")).toBeGreaterThan( + APPROVAL_SNAPSHOT_MAX_BYTES, + ); + expect(serialized.length).toBeLessThan(APPROVAL_SNAPSHOT_MAX_BYTES); + const result = BoundedApprovalSnapshot(base); + expect(result instanceof type.errors).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/runtime.ts b/vendor/intx-types/src/runtime.ts new file mode 100644 index 000000000..03df71bcd --- /dev/null +++ b/vendor/intx-types/src/runtime.ts @@ -0,0 +1,2777 @@ +// Runtime definitions for the Interchange agent harness. +// +// Wire-facing data types (AbortReason, InferenceSource, ToolDefinition, +// HarnessConfig) are arktype validators so they can be composed into +// WebSocket frame validators and used for runtime validation at parse +// boundaries. Behavioral interfaces (ContextStore, MessageTransport, +// ToolRunner, etc.) remain plain TypeScript. + +import { type } from "arktype"; +import type { AuditRecord, ErrorRecord } from "./audit"; +import { WireGrantRule } from "./grant-wire"; +import type { SignalKind } from "./signals"; + +// --------------------------------------------------------------------------- +// Cryptographic Identity (ARCHITECTURE.md § Cryptographic Identity, +// IMPLEMENTATION.md § Cryptographic Identity: Key Formats) +// --------------------------------------------------------------------------- + +/** + * An Ed25519 key pair as raw bytes. The private key is 32 bytes; the public + * key is the corresponding 32-byte compressed point. + * + * Key material is represented as Uint8Array throughout so it stays + * runtime-agnostic (Bun, Node, browser) and never accidentally leaks through + * JSON serialization. + */ +export type KeyPair = { + privateKey: Uint8Array; + publicKey: Uint8Array; +}; + +/** + * A key-bound cryptographic provider. Each instance is constructed with a + * specific agent's Ed25519 key pair and holds the private key internally. + * + * `sign` uses the instance's own private key — no key parameter is accepted. + * `verify` accepts a public key parameter so the holder can verify messages + * from arbitrary senders without constructing a new provider instance. + * + * The in-memory transport stores one CryptoProvider per registered agent and + * calls `crypto.sign(content)` during `send()` without passing keys around. + * + * Key formats (IMPLEMENTATION.md): + * - Ed25519 in SSH format — control plane interactions + * - Ed25519 in PGP format — message-level signatures over SMTP/IMAP + * - Ed25519 in X.509 format — TLS mutual auth certificates + * + * `getPublicKey` returns the raw public key bytes so callers can publish + * them to the control plane or embed them in discovery metadata. + */ +export interface CryptoProvider { + /** + * Sign `content` with the instance's private key. Returns the Ed25519 + * detached signature as raw bytes. + */ + sign(content: Uint8Array): Promise; + + /** + * Sign `payload` with the instance's private key using the SSH signature + * envelope (sshsig). Returns an ASCII-armored SSH SIGNATURE block suitable + * for the `gpgsig` header of a git commit or any other site that consumes + * `git verify-commit`-compatible signatures. The framing differs from + * `sign`'s raw output; callers that need either format should pick the + * matching method rather than reframing the result themselves. + */ + signSSH(payload: string): Promise; + + /** + * Verify that `signature` over `content` was produced by `publicKey`. + * Returns true if the signature is valid; false otherwise. + */ + verify( + content: Uint8Array, + signature: Uint8Array, + publicKey: Uint8Array, + ): Promise; + + /** The public key for this instance, as raw bytes. */ + getPublicKey(): Uint8Array; +} + +/** + * Generate a fresh Ed25519 key pair. The returned pair is used to construct + * a CryptoProvider instance. + */ +export type GenerateKeyPair = () => Promise; + +// --------------------------------------------------------------------------- +// Message Transport (MESSAGE.md § Transport Interface) +// --------------------------------------------------------------------------- + +/** + * Opaque reference to a message in a specific mailbox. Carries the IMAP UID + * and the mailbox name. Passed to fetch, flag, and move operations without + * requiring re-search. + */ +export type MessageRef = { + uid: number; + mailbox: string; +}; + +/** + * Interchange payload types as defined in MESSAGE.md § Payload Types. + * The type field in structured messages matches the Interchange-Type header. + * + * Exposed as both an arktype validator (for runtime validation at parse + * boundaries and tool-argument schemas) and a derived TypeScript union. + */ +export const InterchangeType = type.enumerated( + "conversation.message", + "conversation.join", + "conversation.leave", + "offering.request", + "offering.response", + "offering.error", + "offering.discover", + "offering.catalog", + "payment.required", + "payment.receipt", + "payment.verified", + "approval.request", + "approval.granted", + "approval.denied", + "system.health", + "system.register", + "system.deregister", + "system.credential.refresh", +); +export type InterchangeType = typeof InterchangeType.infer; + +/** + * Attachment for an outbound message. Content is raw bytes; the transport + * handles Content-Transfer-Encoding (base64 for binary, quoted-printable + * for 8-bit text). + */ +export type MessageAttachment = { + name: string; + contentType: string; + data: Uint8Array; +}; + +/** + * A message the harness submits for delivery via SMTP. The transport + * assembles the PGP/MIME multipart structure, signs it with the agent's + * CryptoProvider, and submits it. + * + * Conversation types (conversation.*) carry `content` as text/plain. + * Structured types carry `payload` as application/vnd.interchange+json. + * Providing both is an error. + * + * (MESSAGE.md § Transport Interface › Outbound) + */ +export type OutboundMessage = { + to: string | string[]; + cc?: string | string[]; + subject?: string; + + type: InterchangeType; + + /** Plain text body — used when type is a conversation.* type. */ + content?: string; + + /** Structured JSON body — used when type is a non-conversation type. */ + payload?: Record; + + /** Human-readable summary for structured messages (the text/plain part). */ + summary?: string; + + attachments?: MessageAttachment[]; + + /** Message-ID of the message being replied to. */ + inReplyTo?: string; + + /** Correlation ID linking this message to a pending async request. */ + correlationId?: string; + + /** Reactor session ID from the Interchange-Session-ID header. */ + sessionId?: string; + + /** Tenant ID for the Interchange-Tenant-ID header. */ + tenantId?: string; +}; + +/** + * Receipt returned by `send()`. Contains the assigned Message-ID and + * delivery status. + * + * (MESSAGE.md § Transport Interface › Outbound) + */ +export type SendReceipt = { + messageId: string; + status: "delivered" | "queued"; +}; + +/** + * Parsed headers from an inbound message. Field names follow RFC 5322 and + * the Interchange-specific header conventions from MESSAGE.md § Headers. + */ +export type MessageHeaders = { + from: string; + to: string[]; + cc?: string[]; + date: string; + messageId: string; + inReplyTo?: string; + references?: string[]; + subject?: string; + listId?: string; + + interchangeType?: InterchangeType; + interchangeCorrelationId?: string; + interchangeTenantId?: string; + interchangeAgentId?: string; + interchangeSessionId?: string; + interchangeOfferingId?: string; + interchangeSchemaVersion?: string; + + traceparent?: string; + tracestate?: string; +}; + +/** + * Signature verification status of an inbound message. + * + * - `valid` — signature verified against the sender's public key + * - `invalid` — signature check failed (tampering or wrong key) + * - `unknown` — public key not available for verification + * - `missing` — message was not signed + * + * (MESSAGE.md § Transport Interface › fetchFull) + */ +export const SignatureStatus = type.enumerated( + "valid", + "invalid", + "unknown", + "missing", +); +export type SignatureStatus = typeof SignatureStatus.infer; + +/** + * A parsed MIME part returned by `fetchPart()`. + */ +export type MessagePart = { + contentType: string; + content: Uint8Array; + encoding?: string; +}; + +/** + * MIME tree metadata returned by `fetchStructure()`. Describes content types, + * sizes, and dispositions without transferring content. + * + * (MESSAGE.md § Partial Fetch) + */ +export type BodyStructure = { + contentType: string; + size?: number; + disposition?: string; + parts?: BodyStructure[]; +}; + +/** + * A fully parsed inbound message including structured payload, headers, + * attachments, and signature verification status. + * + * (MESSAGE.md § Transport Interface › fetchFull) + */ +export type InboundMessage = { + ref: MessageRef; + headers: MessageHeaders; + flags: string[]; + + /** Plain text body for conversation.* types. */ + content?: string; + + /** Parsed JSON payload for structured types. */ + payload?: { + type: InterchangeType; + version: string; + body: Record; + }; + + attachments?: MessageAttachment[]; + signatureStatus: SignatureStatus; +}; + +/** + * IMAP mailbox descriptor. + * + * (MESSAGE.md § Inbox Management) + */ +export type Mailbox = { + name: string; + role?: string; + delimiter?: string; +}; + +/** + * Current status of an IMAP mailbox, including QRESYNC identifiers. + * + * (MESSAGE.md § Inbox Management) + */ +export type MailboxStatus = { + total: number; + unseen: number; + recent: number; + uidNext: number; + uidValidity: number; + highestModSeq: number; +}; + +/** + * Structured IMAP search query. Maps the IMAP SEARCH grammar to a typed + * object. Supports recursive boolean composition via `and`, `or`, `not`. + * + * (MESSAGE.md § Search) + */ +export type SearchQuery = { + from?: string; + to?: string; + cc?: string; + bcc?: string; + header?: { field: string; contains: string }; + before?: Date; + after?: Date; + on?: Date; + sentBefore?: Date; + sentAfter?: Date; + sentOn?: Date; + hasFlags?: string[]; + missingFlags?: string[]; + body?: string; + text?: string; + largerThan?: number; + smallerThan?: number; + and?: SearchQuery[]; + or?: SearchQuery[]; + not?: SearchQuery; +}; + +/** + * A thread node returned by `thread()`. Carries a message reference and + * child threads representing replies. Implements the RFC 5256 REFERENCES + * threading algorithm. + * + * (MESSAGE.md § Thread Retrieval) + */ +export type Thread = { + ref: MessageRef; + children: Thread[]; +}; + +/** + * QRESYNC state the harness provides when reconnecting to the transport. + * + * (MESSAGE.md § Synchronization) + */ +export type SyncState = { + uidValidity: number; + uidNext: number; + highestModSeq: number; + knownUids?: number[]; +}; + +/** + * Result of a QRESYNC-style sync operation. + * + * (MESSAGE.md § Synchronization) + */ +export type SyncResult = { + vanished: number[]; + changed: { uid: number; flags: string[] }[]; + newMessages: MessageRef[]; + fullResyncRequired: boolean; +}; + +/** + * Distribution list metadata returned by `createList()`. + * + * (MESSAGE.md § Message Topologies) + */ +export type ListInfo = { + address: string; + name: string; + memberCount: number; + createdAt: string; +}; + +/** + * Event emitted by the mailbox watcher callback. Corresponds to IMAP IDLE + * notifications. + * + * (MESSAGE.md § Real-Time Notification) + */ +export type MailboxEvent = + | { type: "exists"; uid: number; headers: MessageHeaders } + | { type: "flagsChanged"; uid: number; flags: string[] } + | { type: "expunged"; uid: number }; + +/** Unsubscribe function returned by `watch()`. */ +export type Unsubscribe = () => void; + +/** + * The message transport interface. Abstracts SMTP and IMAP behind a + * TypeScript API. Implementations range from real SMTP/IMAP servers to + * in-process stubs that route messages through memory. + * + * All long-running operations accept an AbortSignal for cooperative + * cancellation. + * + * (MESSAGE.md § Transport Interface) + */ +export interface MessageTransport { + // --- Outbound --- + + /** Compose, sign, and deliver a message via SMTP. */ + send(message: OutboundMessage, signal?: AbortSignal): Promise; + + /** Append a raw message to a mailbox (IMAP APPEND). */ + append( + mailbox: string, + message: InboundMessage, + flags?: string[], + signal?: AbortSignal, + ): Promise; + + // --- Mailbox management --- + + listMailboxes(signal?: AbortSignal): Promise; + createMailbox(name: string, signal?: AbortSignal): Promise; + deleteMailbox(name: string, signal?: AbortSignal): Promise; + getMailboxStatus(name: string, signal?: AbortSignal): Promise; + + // --- Message search and retrieval --- + + search( + mailbox: string, + query: SearchQuery, + signal?: AbortSignal, + ): Promise; + + thread( + mailbox: string, + algorithm: "references" | "orderedsubject", + query?: SearchQuery, + signal?: AbortSignal, + ): Promise; + + fetchHeaders(ref: MessageRef, signal?: AbortSignal): Promise; + fetchStructure(ref: MessageRef, signal?: AbortSignal): Promise; + fetchPart( + ref: MessageRef, + partPath: string, + signal?: AbortSignal, + ): Promise; + fetchFull(ref: MessageRef, signal?: AbortSignal): Promise; + + // --- Flag management --- + + setFlags( + ref: MessageRef, + flags: string[], + signal?: AbortSignal, + ): Promise; + + clearFlags( + ref: MessageRef, + flags: string[], + signal?: AbortSignal, + ): Promise; + + // --- Message organization --- + + move(ref: MessageRef, toMailbox: string, signal?: AbortSignal): Promise; + + copy(ref: MessageRef, toMailbox: string, signal?: AbortSignal): Promise; + + expunge(mailbox: string, signal?: AbortSignal): Promise; + + // --- Real-time notification --- + + /** Monitor a mailbox for new messages and flag changes (IMAP IDLE). */ + watch(mailbox: string, callback: (event: MailboxEvent) => void): Unsubscribe; + + // --- Synchronization --- + + /** Efficient reconnection using QRESYNC semantics. */ + sync( + mailbox: string, + knownState: SyncState, + signal?: AbortSignal, + ): Promise; + + // --- Distribution lists --- + + createList( + address: string, + name: string, + signal?: AbortSignal, + ): Promise; + + listMembers(address: string, signal?: AbortSignal): Promise; + + subscribe( + listAddress: string, + subscriberAddress: string, + signal?: AbortSignal, + ): Promise; + + unsubscribe( + listAddress: string, + subscriberAddress: string, + signal?: AbortSignal, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Tool Execution (ARCHITECTURE.md § Tools, INFERENCE.md § Tool Execution) +// --------------------------------------------------------------------------- + +/** + * A tool call as requested by the model. Carries the provider-assigned call + * ID, the tool name, and the parsed arguments. + * + * (INFERENCE.md § Message Format › Content Types) + */ +export const ToolCall = type({ + id: "string", + name: "string", + arguments: "Record", +}); +export type ToolCall = typeof ToolCall.infer; + +/** + * Approver-facing snapshot of the tool call awaiting approval. Built at the + * authz `ask` branch from the tool's definition and the live call, then + * threaded unchanged from the reactor's pending operation through every + * suspend hop to the hub co-write that records it on the approval row. + * + * `name`, `description`, and `inputSchema` mirror the {@link ToolDefinition}; + * `arguments` is the live call's arguments. Carried as a sibling of the pending + * operation's `suspendedCall`, never folded into {@link ToolCall}, so the + * re-dispatch artifact and the approval snapshot stay separate concerns. + */ +export const ApprovalSnapshot = type({ + name: "string", + description: "string", + inputSchema: "Record", + arguments: "Record", +}); +export type ApprovalSnapshot = typeof ApprovalSnapshot.infer; + +/** + * The kind of a control-plane park: a step suspended awaiting an external + * event. `"approval"` and `"input"` park on a reserved + * `signalName(correlationId)` channel; `"signal-relay"` parks on an + * author-chosen name. + * + * - `"approval"` -- the step parked on a tool/authz gate and REQUIRES an + * {@link ApprovalSnapshot}; the runtime notifies the host (`env.onPark`) so + * the sidecar co-writes the approval/correlation rows the hub registers. + * - `"input"` -- the step parked awaiting its next input (e.g. a long-lived + * agent run awaiting the next mail so it can take another turn). It carries + * NO snapshot and does NOT notify the host: the run's owner delivers the + * input on the same channel and the step re-arms. It is a runtime-local + * concept -- deliberately NOT a {@link SignalKind}, so it never touches the + * approval-routing machinery (IPC register frames, the hub co-write, the + * approval columns). + * - `"signal-relay"` -- an onTrigger section container parked on an + * author-named signal so a body child's `awaitSignal` on that name is + * serviced through the deployment run: the external signal is delivered to + * the parent run and the runtime relays it down into the live body child. + * The channel name is the author's free-form signal name, NOT a reserved + * `signalName(correlationId)`, so recovery must branch on this kind BEFORE + * assuming the awaited name is a reserved control-plane channel. Carries no + * snapshot and is not hub-registered. + * + * The kinds are distinguished by an EXPLICIT discriminant everywhere the kind + * flows -- never inferred from the presence or absence of a snapshot, which + * would silently reclassify a malformed snapshot-less approval as another + * park kind rather than failing loud. + */ +export const ControlParkKind = type.enumerated( + "approval", + "input", + "signal-relay", +); +export type ControlParkKind = typeof ControlParkKind.infer; + +/** + * Maximum serialized size, in UTF-8 bytes, of an {@link ApprovalSnapshot} that + * crosses a trust boundary. A tool `inputSchema` is normally single-digit KB; + * a snapshot approaching this bound is malformed or hostile and is rejected at + * the parse boundary rather than co-written onto an approval row. + */ +export const APPROVAL_SNAPSHOT_MAX_BYTES = 131072; + +/** + * {@link ApprovalSnapshot} bounded to {@link APPROVAL_SNAPSHOT_MAX_BYTES}. + * Parse the snapshot through this validator where it crosses a trust boundary + * (the `park.notify` IPC frame, the `parked-correlations.response` IPC frame, + * and the sidecar→hub register frame); internal hops use the unbounded + * {@link ApprovalSnapshot}. `.narrow` bounds the runtime check only — its + * inferred type is identical to {@link ApprovalSnapshot} — so the cap holds only + * where a frame is actually parsed, not merely typed. + */ +export const BoundedApprovalSnapshot = ApprovalSnapshot.narrow( + (snapshot, ctx) => { + const bytes = Buffer.byteLength(JSON.stringify(snapshot), "utf8"); + return ( + bytes <= APPROVAL_SNAPSHOT_MAX_BYTES || + ctx.mustBe(`at most ${APPROVAL_SNAPSHOT_MAX_BYTES} bytes when serialized`) + ); + }, +); +export type BoundedApprovalSnapshot = typeof BoundedApprovalSnapshot.infer; + +/** + * Result of a tool execution. `content` is text or structured data the model + * sees as the tool result. `detail` is additional data that the harness may + * use (e.g., for validation or audit) but that is not shown to the model. + * + * When `isError` is true the model sees the result as an error. When + * `pendingMarker` is present the tool is async — the reactor registers the + * correlation ID and waits for a matching inbound message. + * + * (INFERENCE.md § Tool Execution Semantics) + */ +export const ToolResult = type({ + callId: "string", + content: "string | Record", + "detail?": "unknown", + "isError?": "boolean", + "pendingMarker?": { + status: "'pending'", + correlationId: "string", + "expectedFrom?": "string", + }, +}); +export type ToolResult = typeof ToolResult.infer; + +/** + * The tool runner interface. The harness implements this; the reactor calls + * it when the director requests tool execution. + * + * Parallel execution is modeled by calling `run` concurrently for each call + * in a batch — the interface is per-call, not per-batch. + * + * (ARCHITECTURE.md § Agent Harness › Tools) + */ +export interface ToolRunner { + /** + * Execute a single tool call. Resolves with the result. Must not throw — + * errors are returned as `ToolResult` with `isError: true`. + */ + run(call: ToolCall, signal: AbortSignal): Promise; +} + +// --------------------------------------------------------------------------- +// Inference Event Building Blocks (INFERENCE.md § Event Protocol) +// --------------------------------------------------------------------------- + +/** + * Partial assistant message accumulated during streaming. Carries all + * content blocks seen so far so late-joining subscribers receive current + * state without replaying deltas. + * + * `text` and `thinking` are cumulative across every emitted delta of + * that kind in the current turn — intentionally flat, even when the + * harness's per-index block tracking has split the stream into + * multiple ThinkingBlocks or TextBlocks. Consumers that need per-block + * structure walk the finalized inference.done turn's content[]; this + * snapshot is the live "what bytes has the assistant streamed so + * far" view. + * + * (INFERENCE.md § Event Protocol › Partial State) + */ +export const PartialMessage = type({ + text: "string", + "thinking?": "string", + "toolCalls?": type({ + id: "string", + name: "string", + partialArguments: "string", + }).array(), +}); +export type PartialMessage = typeof PartialMessage.infer; + +/** + * Token usage for a single inference call. Cache read/write counts are + * provider-specific and may be zero when the provider does not report them. + * + * (INFERENCE.md § Token Accounting) + */ +export const TokenUsage = type({ + input: "number", + output: "number", + cacheRead: "number", + cacheWrite: "number", + thinking: "number", +}); +export type TokenUsage = typeof TokenUsage.infer; + +/** + * Slim source descriptor stamped onto `inference.usage` / `inference.done` + * events and onto `ReactorState.lastCycleSource`. + * + * Carries enough identity for state-aware policies (cost gating, budget + * caps, governance triggers, audit) to attribute usage to a specific + * inference source without re-reading the live, mutable `InferenceSource` + * the harness owns. + * + * Deliberately a strict subset of `InferenceSource` — `apiKey` and + * `baseURL` are intentionally excluded. Credentials and endpoints must + * not leak to director-side policy code or to external event consumers. + * Any code path that needs the full source obtains it through the + * harness's source registry, not through this descriptor. + * + * `sourceId` aliases `InferenceSource.id` to disambiguate from message + * ids, turn ids, and session ids in director-side code where `id` alone + * would be ambiguous. + */ +export const LastCycleSource = type({ + sourceId: "string", + provider: "string", + model: "string", +}); +export type LastCycleSource = typeof LastCycleSource.infer; + +// --------------------------------------------------------------------------- +// Internal Turn Format (INFERENCE.md § Message Format) +// --------------------------------------------------------------------------- + +/** + * A single content block within a conversation turn. Provider-agnostic. + * + * (INFERENCE.md § Message Format › Content Types) + */ +const TextBlock = type({ + type: "'text'", + text: "string", + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini attaches a `thoughtSignature` to + // output parts (including plain text); absent for providers that do not + // sign this block kind. + "signature?": "string", +}); + +/** + * How a media payload is carried by a content block. One of three + * variants: inline as a base64-encoded string, by reference to an + * opaque provider-native handle (e.g. a Gemini fileUri, an Anthropic + * file_id), or by public URL the provider fetches itself. The wire + * shape each provider expects is built by the provider adapter; + * MediaSource is the internal, provider-agnostic representation. + * + * (INFERENCE.md § Generalized Multimodal Taxonomy) + */ +const MediaSourceBase64 = type({ + kind: "'base64'", + mimeType: "string", + data: "string", +}); + +const MediaSourceFileReference = type({ + kind: "'file-reference'", + mimeType: "string", + reference: "string", +}); + +const MediaSourceUrl = type({ + kind: "'url'", + mimeType: "string", + url: "string", +}); + +export const MediaSource = MediaSourceBase64.or(MediaSourceFileReference).or( + MediaSourceUrl, +); +export type MediaSource = typeof MediaSource.infer; + +// Exported because `inference.image_output` events reference it by +// name, following the same pattern as `CitationBlock`, +// `CodeExecutionRequestBlock`, and `RedactedThinkingBlock`. +export const ImageBlock = type({ + type: "'image'", + source: MediaSource, + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the + // inlineData part; absent otherwise. + "signature?": "string", +}); +export type ImageBlock = typeof ImageBlock.infer; + +const AudioBlock = type({ + type: "'audio'", + source: MediaSource, +}); + +const VideoBlock = type({ + type: "'video'", + source: MediaSource, +}); + +const DocumentBlock = type({ + type: "'document'", + source: MediaSource, + "title?": "string", + "context?": "string", +}); + +const ThinkingBlock = type({ + type: "'thinking'", + thinking: "string", + "signature?": "string", +}); + +/** + * A thinking block whose content the provider has filtered. The + * opaque `data` blob must echo back verbatim on every follow-up turn + * — Anthropic 400s the request if it changes or goes missing. Treat + * the bytes as opaque: do not log them and do not render them to + * users. + * + * Exported because `inference.thinking.redacted` events reference it + * by name. + */ +export const RedactedThinkingBlock = type({ + type: "'redacted_thinking'", + data: "string", +}); +export type RedactedThinkingBlock = typeof RedactedThinkingBlock.infer; + +/** + * A model-emitted refusal. Produced when a provider's strict-mode + * structured-outputs path declines to satisfy the requested schema — + * OpenAI's `delta.refusal` / `message.refusal` field is the canonical + * wire shape. The `reason` is the accumulated human-readable text the + * model emitted in lieu of conformant output. + * + * Refusal is semantically distinct from `inference.error`: the HTTP + * call succeeded and the model produced a coherent response, but that + * response is "I will not satisfy this schema" rather than schema- + * conformant content. Callers that distinguish policy declines from + * transport/protocol failures should branch on the block type rather + * than treat the assistant turn as an error. + * + * Exported because `inference.refusal.delta` events reference it by + * name and adapters construct RefusalBlocks in the finalized + * AssistantTurn from accumulated delta fragments. + */ +export const RefusalBlock = type({ + type: "'refusal'", + // Refusals must carry text — a zero-length reason corrupts the + // "human-readable text the model emitted in lieu of conformant + // output" contract and would round-trip indistinguishably from a + // refusal block whose payload was lost. The arktype constraint is + // belt-and-braces alongside the adapter's wire-boundary filter on + // empty `delta.refusal` chunks: synthetic fixtures or future + // adapters without that filter still cannot construct a vacuous + // refusal. + reason: "string > 0", +}); +export type RefusalBlock = typeof RefusalBlock.infer; +const ToolCallBlock = type({ + type: "'tool_call'", + id: "string", + name: "string", + arguments: "Record", + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the + // functionCall part; absent otherwise. + "signature?": "string", +}); +/** + * Location of a citation's cited span within its source document. + * The unit of `start` and `end` varies by `kind`: + * - "page": 1-indexed page numbers (Anthropic `page_location`). + * - "char": UTF-16 character offsets, matching JS string semantics + * (Anthropic `char_location`; Gemini `groundingSupports[].segment`). + * - "content-block": index into a structured source's content blocks + * (Anthropic `content_block_location`). + */ +const CitationLocation = type({ + kind: "'page' | 'char' | 'content-block'", + start: "number", + end: "number", +}); + +const CitationSource = type({ + "title?": "string", + // Self-contained dereferenceable URL — populated by providers whose + // citations carry URLs directly (Gemini `groundingChunks[].web.uri`). + "uri?": "string", + // Back-pointer into the request's `documents` array, populated by + // providers that cite uploaded documents by position (Anthropic + // `document_index`). + "documentRef?": type({ index: "number" }), +}); + +/** + * A citation that supports a span of assistant text. Consumers + * receiving a CitationBlock without a paired source-block index MUST + * attribute it by adjacency to the nearest preceding TextBlock in the + * same turn. + * + * Citations are deliberately excluded from ToolResultBlock.content + * — they annotate model output, not tool output. + * + * Exported because `inference.citation` events reference it by name, + * following the same pattern as `AssistantTurn`, `ToolCall`, and + * `ToolResult`. See the `inference.citation` event docstring for how + * a paired source-block index is carried on the wire and consumed by + * the harness. + */ +export const CitationBlock = type({ + type: "'citation'", + // The exact substring of the preceding TextBlock this citation + // supports. Both providers emit it; required for inspection and + // for fallback offset reconstruction. + citedText: "string", + source: CitationSource, + "location?": CitationLocation, + // UTF-16 character offsets into the preceding TextBlock's text. + // Providers that emit offsets natively populate these directly; + // adapters that derive offsets from a cited substring populate + // them only when the substring appears unambiguously in the + // preceding text. Omitted when the offset cannot be determined. + "textOffset?": type({ start: "number", end: "number" }), +}); +export type CitationBlock = typeof CitationBlock.infer; + +/** + * A structured safety signal on model output or request filtering. + * + * The name `SafetyRatingBlock` follows the issue vocabulary; the + * payload is derived from the first real Gemini capture that engaged + * the structured classifier (2026-07-28). That wire shape is + * prompt-level only: + * + * `promptFeedback: { blockReason: "PROHIBITED_CONTENT" }` + * + * with no candidates and no per-category `safetyRatings` arrays. So + * this block carries `blockReason` and does **not** invent category / + * probability / blocked fields. When a future capture surfaces + * candidate-level ratings, extend the type from those bytes rather + * than from the API reference. + * + * Deliberately excluded from ToolResultBlock.content — safety + * signals annotate model/request filtering, not tool output. + * + * Exported because `inference.safety_rating` events reference it by + * name. + */ +export const SafetyRatingBlock = type({ + type: "'safety_rating'", + // Provider-native block reason string (observed: "PROHIBITED_CONTENT"). + // Open string so a new reason token does not force a type bump. + blockReason: "string > 0", +}); +export type SafetyRatingBlock = typeof SafetyRatingBlock.infer; + +/** + * Human-readable rendering of a SafetyRatingBlock for reply text, + * timeline summaries, and request-history rewrites when a provider + * has no input wire shape for safety_rating. Single owner of the + * display string so reply / history / transform stay in lockstep. + */ +export function formatSafetyRatingText(block: SafetyRatingBlock): string { + return `Request blocked: ${block.blockReason}`; +} + +/** + * The model's request to execute code via a server-side execution tool. + * Paired with a CodeExecutionResultBlock carrying the same `id` as the + * result's `requestId`. Streaming order within a single execution is + * `inference.code_execution.start` → zero or more + * `inference.code_execution.delta` → `inference.code_execution.result`, + * uninterrupted by other events that share the same `requestId`; events + * with different `requestId`s or for other block kinds at distinct + * `index`es may interleave. + * + * Exported because `inference.code_execution.start` references it by + * name. + */ +export const CodeExecutionRequestBlock = type({ + type: "'code_execution_request'", + // Identifier for the execution request. Populated from the + // provider's call id where one exists (Anthropic + // `srvtoolu_...`); synthesized by the adapter for providers that + // don't emit one (Gemini), using a deterministic per-response + // position-based scheme so replays match. + id: "string", + // Source code the model is asking to execute. + code: "string", + // Language hint. Absent when the provider does not emit one; + // adapters MUST NOT default this — callers narrow on its + // presence rather than fall through to a guessed language. + "language?": "string", + // Opaque provider signature authenticating this block, echoed back + // verbatim on follow-up turns. Gemini rides a `thoughtSignature` on the + // executableCode part; absent otherwise. + "signature?": "string", +}); +export type CodeExecutionRequestBlock = typeof CodeExecutionRequestBlock.infer; + +/** + * The result of executing a CodeExecutionRequestBlock. The `requestId` + * back-points to the request block's `id`. Status is normalized across + * providers; raw provider signals (return code, native outcome string, + * abort reason) are preserved on optional fields for callers that need + * them. + * + * File outputs from code execution (e.g. generated plots that + * Anthropic returns in `code_execution_tool_result.content`) are NOT + * modeled by this block today. The block carries no field for them; + * surfacing file outputs is a separate concern. + * + * Exported because `inference.code_execution.result` references it by + * name. + */ +export const CodeExecutionResultBlock = type({ + type: "'code_execution_result'", + // Back-pointer to the originating CodeExecutionRequestBlock.id. + requestId: "string", + // Normalized outcome. Translated from provider-specific signals: + // - Anthropic: derived from `return_code` (0 → "ok", non-zero → + // "error") and `abort_reason` (non-null → "aborted" or + // "timeout" per the reason). + // - Gemini: derived from the `outcome` enum + // (OUTCOME_OK → "ok", OUTCOME_FAILED → "error", + // OUTCOME_DEADLINE_EXCEEDED → "timeout", etc.). + status: "'ok' | 'error' | 'aborted' | 'timeout'", + // Standard output. Providers that don't split stdout from stderr + // (Gemini) map their combined `output` here and leave `stderr` empty. + "stdout?": "string", + // Standard error. Empty for providers that don't split. + "stderr?": "string", + // Provider-native numeric return code when available + // (Anthropic `return_code`). Absent for providers whose outcome + // is enum-only (Gemini). + "returnCode?": "number", + // Provider-native outcome string preserved verbatim for callers + // that need the raw signal (Gemini `OUTCOME_OK` / + // `OUTCOME_FAILED` / `OUTCOME_DEADLINE_EXCEEDED` / ...). Absent + // when the provider does not emit one (Anthropic). + "providerOutcome?": "string", + // Human-readable reason populated when status is "aborted" + // (Anthropic `abort_reason`). Absent otherwise. + "abortReason?": "string", +}); +export type CodeExecutionResultBlock = typeof CodeExecutionResultBlock.infer; + +const ToolResultBlock = type({ + type: "'tool_result'", + callId: "string", + // Deliberately narrow: tool results carry user-facing media, not + // CitationBlocks (citations annotate the model's text output), not + // SafetyRatingBlocks (safety signals annotate model/request + // filtering), and not CodeExecution blocks (server-side code + // execution is a distinct lifecycle from the user-tool round-trip). + content: TextBlock.or(ImageBlock) + .or(AudioBlock) + .or(VideoBlock) + .or(DocumentBlock) + .array(), + "detail?": "unknown", + "isError?": "boolean", +}); + +export const ContentBlock = TextBlock.or(ThinkingBlock) + .or(RedactedThinkingBlock) + .or(RefusalBlock) + .or(ImageBlock) + .or(AudioBlock) + .or(VideoBlock) + .or(DocumentBlock) + .or(CitationBlock) + .or(SafetyRatingBlock) + .or(CodeExecutionRequestBlock) + .or(CodeExecutionResultBlock) + .or(ToolCallBlock) + .or(ToolResultBlock); +export type ContentBlock = typeof ContentBlock.infer; + +/** + * A turn in the internal conversation history. The `model` field records + * which provider model produced this turn (present only on assistant + * turns). Used by cross-provider transformation to strip or preserve + * thinking blocks. + * + * (INFERENCE.md § Message Format) + */ +export type ConversationTurn = { + role: "user" | "assistant" | "system"; + content: ContentBlock[]; + model?: string; + timestamp: number; +}; + +/** + * A completed assistant turn returned in `inference.done`. Narrower type + * than ConversationTurn to make the inference boundary explicit. + */ +export const AssistantTurn = type({ + role: "'assistant'", + content: ContentBlock.array(), + model: "string", + timestamp: "number", +}); +export type AssistantTurn = typeof AssistantTurn.infer; + +// --------------------------------------------------------------------------- +// Error Classification (INFERENCE.md § Error Classification) +// --------------------------------------------------------------------------- + +/** + * Classified inference error. The category determines the reactor's default + * response; the director can override per its policy. + * + * (INFERENCE.md § Error Classification) + */ +export const InferenceError = type({ + category: type.enumerated( + "retryable", + "context_overflow", + "credential_failure", + "quota_exhausted", + "fatal", + "aborted", + "timeout", + "protocol_mismatch", + ), + message: "string", + "statusCode?": "number", + "retryAfterMs?": "number", + "raw?": "unknown", +}); +export type InferenceError = typeof InferenceError.infer; + +// --------------------------------------------------------------------------- +// Agent Reactor (INFERENCE.md § Agent Reactor) +// --------------------------------------------------------------------------- + +/** + * Gate types that can block the reactor. + * + * (INFERENCE.md § Gates) + */ +export const GateType = type.enumerated( + "approval", + "payment", + "credential", + "budget", + "child_completion", + "message_response", +); +export type GateType = typeof GateType.infer; + +/** + * Fork mode. `independent` creates a divergent reactor with its own context. + * `child` creates a reactor that reports results back to the parent. + * + * (INFERENCE.md § Forking) + */ +export const ForkMode = type.enumerated("independent", "child"); +export type ForkMode = typeof ForkMode.infer; + +// --------------------------------------------------------------------------- +// Inference Event Protocol (INFERENCE.md § Event Protocol) +// --------------------------------------------------------------------------- + +/** + * Wire-safe representation of InboundMessage for use in InferenceEvent + * variants. The runtime InboundMessage type contains Uint8Array fields + * (MessageAttachment.data) that cannot survive JSON serialization, so the + * wire validator uses `unknown` for attachment data and accepts whatever + * JSON.parse produces. + */ +const WireInboundMessage = type({ + ref: { uid: "number", mailbox: "string" }, + headers: "Record", + flags: "string[]", + "content?": "string", + "payload?": "object", + "attachments?": "unknown[]", + signatureStatus: type.enumerated("valid", "invalid", "unknown", "missing"), +}); + +/** + * A single event in the inference event protocol. Every event carries a + * monotonic session-scoped sequence number. + * + * Event types are namespaced: `inference.*`, `tool.*`, `reactor.*`, + * `fork.*`, `message.*`, `custom.*`. + * + * (INFERENCE.md § Event Protocol) + */ +export const InferenceEvent = type({ + type: "'inference.start'", + seq: "number", + data: { model: "string" }, +}) + .or({ + type: "'inference.thinking.delta'", + seq: "number", + data: { + token: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.block.signature'", + seq: "number", + data: { signature: "string", "index?": "number" }, + }) + .or({ + type: "'inference.thinking.redacted'", + seq: "number", + data: { redactedThinking: RedactedThinkingBlock, "index?": "number" }, + }) + .or({ + type: "'inference.text.delta'", + seq: "number", + data: { + token: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.refusal.delta'", + seq: "number", + data: { + token: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.tool_call.start'", + seq: "number", + data: { + callId: "string", + name: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.tool_call.delta'", + seq: "number", + data: { + callId: "string", + argumentFragment: "string", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.tool_call.end'", + seq: "number", + data: { + callId: "string", + name: "string", + arguments: "Record", + partial: PartialMessage, + "index?": "number", + }, + }) + .or({ + type: "'inference.usage'", + seq: "number", + data: { usage: TokenUsage, source: LastCycleSource }, + }) + .or({ + type: "'inference.done'", + seq: "number", + data: { + turn: AssistantTurn, + usage: TokenUsage, + source: LastCycleSource, + "pacingDelayMs?": "number", + }, + }) + .or({ + type: "'inference.error'", + seq: "number", + data: { error: InferenceError, partial: PartialMessage }, + }) + .or({ + type: "'inference.retry'", + seq: "number", + data: { + attempt: "number", + delayMs: "number", + previousError: InferenceError, + }, + }) + .or({ + type: "'inference.citation'", + seq: "number", + // `index`, when present, names the source content block (typically + // a TextBlock) the citation annotates. The harness uses it to + // interleave the citation into the finalized turn's `content[]` + // immediately after the matching block. Adapters whose wire + // protocol does not carry per-citation block indices omit the + // field; the harness then appends those citations at the end of + // `content[]` and consumers attribute them to the nearest + // preceding TextBlock per the CitationBlock docstring. + data: { citation: CitationBlock, "index?": "number" }, + }) + .or({ + type: "'inference.safety_rating'", + seq: "number", + // Prompt-level structured safety signal (observed Gemini + // `promptFeedback.blockReason`). No candidate index: the first + // capture has zero candidates. Harness appends the block to the + // finalized turn's `content[]`. + data: { safetyRating: SafetyRatingBlock }, + }) + .or({ + type: "'inference.code_execution.start'", + seq: "number", + data: { request: CodeExecutionRequestBlock, "index?": "number" }, + }) + .or({ + type: "'inference.code_execution.delta'", + seq: "number", + // requestId correlates fragments back to the originating + // CodeExecutionRequestBlock; index is the positional hint into + // the response's content-block stream. They are independent: a + // single response may stream code execution for multiple + // requests interleaved, distinguished by requestId; index lets + // the harness's per-block accumulator route the fragment to + // the correct block when the array isn't yet finalized. + data: { + requestId: "string", + codeFragment: "string", + "index?": "number", + }, + }) + .or({ + type: "'inference.code_execution.result'", + seq: "number", + data: { result: CodeExecutionResultBlock, "index?": "number" }, + }) + .or({ + type: "'inference.image_output'", + seq: "number", + // Fires mid-stream when an adapter finalizes an image-output + // block, signaling that the image is ready for downstream + // handoff before the full inference.done lands. The wrapped + // ImageBlock typically carries a base64 MediaSource — the + // payload can be large (Gemini's image-output captures show + // ~1MB inline blobs); consumers that subscribe to this event + // should treat it as a non-trivial transport size. + data: { image: ImageBlock, "index?": "number" }, + }) + .or({ + type: "'tool.start'", + seq: "number", + data: { call: ToolCall }, + }) + .or({ + type: "'tool.update'", + seq: "number", + data: { callId: "string", partial: "string" }, + }) + .or({ + type: "'tool.done'", + seq: "number", + data: { result: ToolResult }, + }) + .or({ + type: "'message.queued'", + seq: "number", + data: { message: WireInboundMessage }, + }) + .or({ + type: "'message.run.started'", + seq: "number", + data: { + messageId: "string", + messageRunId: "string", + receivedAt: "number", + }, + }) + .or({ + type: "'message.run.ended'", + seq: "number", + data: { + messageRunId: "string", + messageId: "string", + status: type.enumerated("completed", "failed"), + "error?": { + message: "string", + "kind?": "string", + }, + }, + }) + .or({ + type: "'message.correlated'", + seq: "number", + data: { message: WireInboundMessage, correlationId: "string" }, + }) + .or({ + type: "'connector.reply'", + seq: "number", + data: { content: "string", "checkpointHash?": "string" }, + }) + .or({ + type: "'reactor.start'", + seq: "number", + data: "object", + }) + .or({ + type: "'reactor.gate.blocked'", + seq: "number", + data: { + reason: GateType, + gateId: "string", + "correlationId?": "string", + "approvalSnapshot?": ApprovalSnapshot, + }, + }) + .or({ + type: "'reactor.gate.cleared'", + seq: "number", + data: { + gateId: "string", + reason: type.enumerated("resolved", "timeout", "shutdown"), + }, + }) + .or({ + type: "'reactor.done'", + seq: "number", + data: "object", + }) + .or({ + type: "'reactor.error'", + seq: "number", + data: { error: "string", fatal: "boolean" }, + }) + .or({ + type: "'fork.created'", + seq: "number", + data: { forkId: "string", parentId: "string", mode: ForkMode }, + }) + .or({ + type: "'fork.done'", + seq: "number", + data: { forkId: "string", "result?": "unknown" }, + }) + .or({ + type: "'fork.error'", + seq: "number", + data: { forkId: "string", error: "string" }, + }) + .or({ + type: "'fork.aborted'", + seq: "number", + data: { forkId: "string" }, + }) + .or({ + type: /^custom\./, + seq: "number", + data: "Record", + }); +// The TypeScript type is defined manually rather than inferred from the +// validator because the `custom.*` variant uses a regex pattern which +// arktype infers as `string`. A bare `string` in the discriminant position +// prevents TypeScript from narrowing the union in switch statements. +// The manually defined type uses a `custom.${string}` template literal +// for that variant, preserving the narrowing behavior downstream code +// relies on. +export type InferenceEvent = + | { type: "inference.start"; seq: number; data: { model: string } } + | { + type: "inference.thinking.delta"; + seq: number; + data: { token: string; partial: PartialMessage; index?: number }; + } + | { + type: "inference.block.signature"; + seq: number; + data: { signature: string; index?: number }; + } + | { + type: "inference.thinking.redacted"; + seq: number; + data: { redactedThinking: RedactedThinkingBlock; index?: number }; + } + | { + type: "inference.text.delta"; + seq: number; + data: { token: string; partial: PartialMessage; index?: number }; + } + | { + type: "inference.refusal.delta"; + seq: number; + data: { token: string; partial: PartialMessage; index?: number }; + } + | { + type: "inference.tool_call.start"; + seq: number; + data: { + callId: string; + name: string; + partial: PartialMessage; + index?: number; + }; + } + | { + type: "inference.tool_call.delta"; + seq: number; + data: { + callId: string; + argumentFragment: string; + partial: PartialMessage; + index?: number; + }; + } + | { + type: "inference.tool_call.end"; + seq: number; + data: { + callId: string; + name: string; + arguments: Record; + partial: PartialMessage; + index?: number; + }; + } + | { + type: "inference.usage"; + seq: number; + data: { usage: TokenUsage; source: LastCycleSource }; + } + | { + type: "inference.done"; + seq: number; + data: { + turn: AssistantTurn; + usage: TokenUsage; + source: LastCycleSource; + pacingDelayMs?: number; + }; + } + | { + type: "inference.error"; + seq: number; + data: { error: InferenceError; partial: PartialMessage }; + } + | { + /** + * Emitted between attempts when the per-call retry policy decides + * to retry after an error. `attempt` is the 1-indexed number of + * the attempt that just **failed** — the same value the policy + * saw on its `RetrySituation.attempt` reading. `delayMs` is the + * delay the wrapper will apply before the next attempt starts; + * `previousError` carries the classified error that triggered + * the retry. The event is not emitted when the policy aborts. + */ + type: "inference.retry"; + seq: number; + data: { + attempt: number; + delayMs: number; + previousError: InferenceError; + }; + } + | { + type: "inference.citation"; + seq: number; + data: { citation: CitationBlock; index?: number }; + } + | { + type: "inference.safety_rating"; + seq: number; + data: { safetyRating: SafetyRatingBlock }; + } + | { + type: "inference.code_execution.start"; + seq: number; + data: { request: CodeExecutionRequestBlock; index?: number }; + } + | { + type: "inference.code_execution.delta"; + seq: number; + data: { requestId: string; codeFragment: string; index?: number }; + } + | { + type: "inference.code_execution.result"; + seq: number; + data: { result: CodeExecutionResultBlock; index?: number }; + } + | { + type: "inference.image_output"; + seq: number; + data: { image: ImageBlock; index?: number }; + } + | { type: "tool.start"; seq: number; data: { call: ToolCall } } + | { + type: "tool.update"; + seq: number; + data: { callId: string; partial: string }; + } + | { type: "tool.done"; seq: number; data: { result: ToolResult } } + | { + type: "message.queued"; + seq: number; + data: { message: InboundMessage }; + } + | { + /** + * Per-message run-bracket open. Emitted by the reactor when it + * dequeues an inbound mail message and begins per-message work. + * + * `messageRunId` is reactor-minted, unique per dequeue. It is + * non-negotiable for crash-replay correlation: the reactor can + * legitimately dequeue the same `messageId` more than once across + * a crash + replay cycle, so two bracket-open events with the + * same `messageId` and no run-id cannot be unambiguously paired + * with their `message.run.ended` counterparts. + */ + type: "message.run.started"; + seq: number; + data: { + messageId: string; + messageRunId: string; + receivedAt: number; + }; + } + | { + /** + * Per-message run-bracket close. Pairs with `message.run.started` + * by `messageRunId`. `messageId` is carried redundantly so log + * readers can correlate without a join against the open event. + * + * The `status` enum is `"completed" | "failed"` only. + * Cancellation lives in the workflow-runtime's + * `CancelRequested` -> `RunFailed` vocabulary, not on the + * reactor's bracket: the reactor does not run a state machine + * and what it observes when cancellation arrives is a harness + * abort, which is structurally `"failed"` with a specific + * `error.kind`. + * + * `error.kind` is documented as one of + * `"inference_error" | "tool_error" | "reactor_fatal" | + * "harness_aborted"` initially, extensible as new failure + * categories surface. + */ + type: "message.run.ended"; + seq: number; + data: { + messageRunId: string; + messageId: string; + status: "completed" | "failed"; + error?: { + message: string; + kind?: string; + }; + }; + } + | { + type: "message.correlated"; + seq: number; + data: { message: InboundMessage; correlationId: string }; + } + | { + type: "connector.reply"; + seq: number; + data: { content: string; checkpointHash?: string }; + } + | { type: "reactor.start"; seq: number; data: Record } + | { + type: "reactor.gate.blocked"; + seq: number; + data: { + reason: GateType; + gateId: string; + correlationId?: string; + approvalSnapshot?: ApprovalSnapshot; + }; + } + | { + type: "reactor.gate.cleared"; + seq: number; + data: { + gateId: string; + reason: "resolved" | "timeout" | "shutdown"; + }; + } + | { type: "reactor.done"; seq: number; data: Record } + | { + type: "reactor.error"; + seq: number; + data: { error: string; fatal: boolean }; + } + | { + type: "fork.created"; + seq: number; + data: { forkId: string; parentId: string; mode: ForkMode }; + } + | { + type: "fork.done"; + seq: number; + data: { forkId: string; result?: unknown }; + } + | { + type: "fork.error"; + seq: number; + data: { forkId: string; error: string }; + } + | { type: "fork.aborted"; seq: number; data: { forkId: string } } + | { + type: `custom.${string}`; + seq: number; + data: Record; + }; + +// Load-bearing drift guards for the dual-maintained `reactor.gate.blocked` +// event. The arktype `InferenceEvent` validator and the hand-written +// `InferenceEvent` type are kept in lockstep by hand (the `custom.*` regex +// variant forces the manual mirror). arktype passes undeclared keys through at +// runtime, so a schema that dropped `approvalSnapshot` would not fail at +// runtime. Projecting the field off each inferred shape makes it load-bearing: +// `tsc` errors if either mirror stops carrying it, mirroring the +// `_persistedSuspendedCall` guard in storage-isogit. +const _arkGateBlockedApprovalSnapshot = ( + data: Extract< + typeof InferenceEvent.infer, + { type: "reactor.gate.blocked" } + >["data"], +): ApprovalSnapshot | undefined => data.approvalSnapshot; +void _arkGateBlockedApprovalSnapshot; + +const _tsGateBlockedApprovalSnapshot = ( + data: Extract["data"], +): ApprovalSnapshot | undefined => data.approvalSnapshot; +void _tsGateBlockedApprovalSnapshot; + +/** + * Validate unknown data as an InferenceEvent. ArkType's regex-based validator + * infers `custom.*` event types as `string`, but the manual InferenceEvent type + * uses a `custom.${string}` template literal for switch narrowing. This function + * centralizes that single unavoidable cast. + */ +export function parseInferenceEvent( + data: unknown, +): InferenceEvent | type.errors { + const result = InferenceEvent(data); + if (result instanceof type.errors) return result; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- arktype regex infers as string; manual type uses template literal + return result as InferenceEvent; +} + +/** + * A pending async operation registered in the reactor's async state. + * Correlates an outbound message (or payment/approval request) to the + * expected inbound response. + * + * (INFERENCE.md § Correlation) + */ +export type PendingOperation = { + correlationId: string; + kind: SignalKind; + expectedFrom?: string; + registeredAt: number; + gateId: string; + /** + * Absolute deadline (epoch ms) for the gate that parks this operation. + * Persisted so that rehydration after a restart re-arms the gate with the + * remaining time against the original deadline rather than restarting the + * countdown. Absent for operations parked with no deadline. + */ + timeoutAt?: number; + /** + * The tool call that was suspended when this operation parked. Captured for + * `kind: "approval"` operations minted from the ask flow so the approved + * call can be re-run on resume. Absent for operations parked by the + * director path (async-tool pending markers), which carry no tool call. + */ + suspendedCall?: ToolCall; + /** + * Approver-facing snapshot of `suspendedCall`, built at the authz `ask` + * branch from the tool definition and the live arguments. A sibling of + * `suspendedCall`, not a widening of it: `suspendedCall` is the re-dispatch + * artifact, this is what the approver decides on. Present only for ask-rail + * operations that carry a `suspendedCall`; absent for async-tool pending + * markers. Threaded through the suspend hops to the hub co-write. + */ + approvalSnapshot?: ApprovalSnapshot; +}; + +/** + * Complete reactor state visible to the director decision function. + * + * `tokenUsage` is the cumulative usage across the session. + * + * `lastCycleUsage` and `lastCycleSource` describe the most recent + * *successful* inference call. They move together: both null before the + * first completion; every `inference.done` sets both atomically. + * `inference.error` does not clear either — the pair always reflects the + * last cycle that produced a well-defined turn and usage. The director's + * `afterInferenceDone` hook fires only on `inference.done`, so policy + * code never observes a torn or stale-vs-fresh window. + * + * The per-cycle values support compaction triggers that key off recent + * input cost rather than session totals, and state-aware policies (cost + * gating, budget caps, governance triggers) that need to attribute + * usage to the source that produced it. + * + * (INFERENCE.md § Agent Reactor › Director Decision Function) + */ +export type ReactorState = { + turns: ConversationTurn[]; + activeForks: { forkId: string; mode: ForkMode }[]; + pendingOperations: PendingOperation[]; + activeGates: { gateId: string; type: GateType; timeoutAt: number }[]; + tokenUsage: TokenUsage; + lastCycleUsage: TokenUsage | null; + lastCycleSource: LastCycleSource | null; + sessionId: string; +}; + +/** + * Actions the director can direct the reactor to take. + * + * (INFERENCE.md § Agent Reactor › Actions) + */ +export type ReactorAction = + | { + type: "infer"; + options?: InferenceOptions; + } + | { + type: "execute_tools"; + calls: ToolCall[]; + parallel?: boolean; + addToHistory?: boolean; + } + | { + type: "suspend"; + gate: { + type: GateType; + gateId: string; + timeoutMs: number; + correlationId?: string; + }; + } + | { + type: "fork"; + mode: ForkMode; + forkId: string; + } + | { + type: "emit"; + eventType: `custom.${string}`; + data: Record; + } + | { + type: "reply"; + content: string; + } + | { type: "checkpoint"; message: string } + | { type: "compact"; compactor: string; reason: string } + | { type: "wait" } + | { type: "done" }; + +/** + * The capabilities object passed to the director. Mirrors the `ReactorAction` + * union — provides a type-safe way for the director to construct actions. + * + * (INFERENCE.md § Agent Reactor › Director Decision Function) + */ +export type ReactorCapabilities = { + infer(options?: InferenceOptions): ReactorAction; + executeTools( + calls: ToolCall[], + parallel?: boolean, + addToHistory?: boolean, + ): ReactorAction; + suspend(gate: { + type: GateType; + gateId: string; + timeoutMs: number; + correlationId?: string; + }): ReactorAction; + fork(mode: ForkMode, forkId: string): ReactorAction; + emit( + eventType: `custom.${string}`, + data: Record, + ): ReactorAction; + reply(content: string): ReactorAction; + checkpoint(message?: string): ReactorAction; + compact(compactor: string, reason: string): ReactorAction; + wait(): ReactorAction; + done(): ReactorAction; +}; + +/** + * The inbound events delivered to the director decision function. + * + * `resume.execute_tools` is raised by the reactor when an approval resolves + * and a parked tool call must be re-run on resume. It carries the calls the + * reactor is about to dispatch so the director can seed its outstanding + * tool-result count before those calls' `tool.done` events arrive — the + * reactor drives the execution, the director counts the results. Without this + * seed the count would sit at zero and the first `tool.done` would drive an + * accidental re-inference off a negative count. + * + * `resume.tool_result` is raised by the reactor when a parked approval ends + * without running its tool — a rejected decision or a gate timeout. It carries + * a synthetic error tool result that answers the parked call so history stays + * well-formed; the director appends it and re-infers exactly once. No tool + * runs, so it seeds no outstanding-result count. + * + * (INFERENCE.md § Agent Reactor › Reactor Structure) + */ +export type ReactorInboundEvent = + | { type: "message.received"; message: InboundMessage } + | { + type: "inference.done"; + turn: AssistantTurn; + usage: TokenUsage; + source: LastCycleSource; + } + | { type: "inference.error"; error: InferenceError; partial: PartialMessage } + | { type: "tool.done"; result: ToolResult } + | { + type: "reactor.gate.cleared"; + gateId: string; + reason: "resolved" | "timeout" | "shutdown"; + } + | { type: "resume.execute_tools"; calls: ToolCall[] } + | { type: "resume.tool_result"; result: ToolResult } + | { type: "abort"; reason: AbortReason }; + +/** + * The core director is a single decision function: given an event and the + * current reactor state, return one or more actions. + * + * If the director throws, the reactor catches the exception, emits + * `reactor.error`, and initiates graceful shutdown. + * + * (INFERENCE.md § Reactor Director › Core Director) + */ +export interface ReactorDirector { + decide( + event: ReactorInboundEvent, + state: ReactorState, + capabilities: ReactorCapabilities, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Director Extension Hooks (INFERENCE.md § Reactor Director › Extension Hooks) +// --------------------------------------------------------------------------- + +/** + * Decision returned by a `BeforeToolExtension`. + * + * - `allow` — the tool proceeds. + * - `block` — the tool is answered with an error result carrying `reason`; + * the call is done. + * - `suspend` — the call is parked awaiting an external decision. The reactor + * registers `gate`, persists `pendingOp`, and does not answer the call: it + * is neither run nor error-completed. `gate.timeoutAt` is the absolute + * deadline (epoch ms) so the reactor can compute the remaining time; the + * `correlationId` on both `gate` and `pendingOp` ties an inbound resolution + * back to the suspension. + */ +export type BeforeToolDecision = + | { type: "allow" } + | { type: "block"; reason: string } + | { + type: "suspend"; + gate: { + type: GateType; + gateId: string; + correlationId: string; + timeoutAt: number; + }; + pendingOp: PendingOperation; + }; + +/** + * Extension that runs before a tool call is executed. Returns a + * `BeforeToolDecision`: `allow` lets the call run, `block` answers it with an + * error result, `suspend` parks it awaiting an external decision. + * + * `grantOneShot` registers a within-cycle bypass token keyed on a + * `ToolCall.id`: the next `beforeTool` for that id skips a suspension it would + * otherwise raise, consuming the token as it does so. It is optional because + * only extensions that can suspend a call have anything to bypass; extensions + * that never suspend omit it. + */ +export interface BeforeToolExtension { + beforeTool( + call: ToolCall, + state: ReactorState, + signal: AbortSignal, + ): Promise; + grantOneShot?(id: string): void; +} + +/** + * Extension that runs after a tool result is produced. Can modify the result + * (redaction, enrichment, audit logging). Extensions run in order. + */ +export interface AfterToolExtension { + afterTool( + result: ToolResult, + call: ToolCall, + state: ReactorState, + signal: AbortSignal, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Context Strategies: Transforms and Compactors +// (INFERENCE.md § Context Management, § Tool Result Lifecycle) +// --------------------------------------------------------------------------- + +/** + * Durable description of a single strategy invocation. Written to the + * per-cycle manifest in the context store so that future operators can + * reconstruct exactly which strategy made which change, with what + * parameters, and why. + * + * - `strategy` is the implementation name (e.g. `"size-cap"`). + * - `version` is the implementation version. Changes to the strategy's + * behavior bump the version so old manifest entries remain unambiguous. + * - `parameters` records the configuration the strategy ran with. + * - `reason` is a short machine-readable cause label + * (e.g. `"exceeded-cap"`, `"overflow-recovery"`). + * - `decisions` records strategy-specific details about what was actually + * done (e.g. the keep count, the spill key, the original byte size). + */ +export const TransformRecord = type({ + strategy: "string", + version: "string", + parameters: "Record", + reason: "string", + decisions: "Record", +}); +export type TransformRecord = typeof TransformRecord.infer; + +/** + * Per-invocation context passed to every `ContextStrategy.apply` call. + * `state` is the reactor's snapshot at the moment the strategy runs; + * `trigger` is a short label describing why the strategy was invoked + * (e.g. `"tool-result-ingest"`, `"pre-inference"`, `"director-request"`). + */ +export interface StrategyContext { + readonly state: ReactorState; + readonly trigger: string; +} + +/** + * Optional blob attachment emitted by a strategy. The reactor writes each + * blob to the context store's working tree via `ContextStore.writeBlob` + * (Phase 2) so the data is durable and migrates with the conversation. + */ +export type StrategyBlob = { + key: string; + bytes: Uint8Array; + contentType?: string; +}; + +/** + * Result returned by `ContextStrategy.apply`. Carries the transformed + * output, a `TransformRecord` describing what happened, and any blobs + * that should be persisted in the context store. + */ +export interface StrategyResult { + output: O; + record: TransformRecord; + blobs?: StrategyBlob[]; +} + +/** + * Generic base interface for content-mutating strategies. The role-specific + * aliases below specialize `I` and `O` for tool-result ingestion, pre- + * inference context shaping, and explicit compaction. + * + * Strategies are pure with respect to the context store: they describe what + * should change via their return value. The reactor decides where to write + * the result (history, prompt, manifest) and which blobs to persist. + */ +export interface ContextStrategy { + readonly name: string; + readonly version: string; + apply(input: I, ctx: StrategyContext): Promise>; +} + +/** + * Runs on each tool result entering history. Output is appended to the + * conversation; any emitted blobs are written to the context store's + * `tool-output/` directory. + */ +export type ToolResultTransform = ContextStrategy< + { call: ToolCall; result: ToolResult }, + ToolResult +>; + +/** + * Runs in order before every inference call, producing the materialized + * prompt. Output is written to `prompt.jsonl` for that cycle; the durable + * history in `turns.jsonl` is left untouched. + * + * (INFERENCE.md § Async State Awareness › Pending Status Injection) + */ +export type ContextTransform = ContextStrategy< + ConversationTurn[], + ConversationTurn[] +>; + +/** + * Named compaction strategy. Registered in a registry on the reactor and + * invoked explicitly via the director's `compact` action. Output overwrites + * `turns.jsonl`; a `TransformRecord` is appended to the manifest. + */ +export type Compactor = ContextStrategy; + +// --------------------------------------------------------------------------- +// Blob Reader (INFERENCE.md § Tool Result Lifecycle) +// --------------------------------------------------------------------------- + +/** + * Read-only capability for resolving `tool-output:///{callId}` URIs to the + * underlying blob bytes. A `ToolResultTransform` that spills oversized tool + * output writes a blob via `ContextStore.writeBlob` and returns a pointer of + * the form `tool-output:///{callId}`; the agent's read tool reaches the spill + * by calling `BlobReader.read(uri)`. + * + * The URI scheme is deliberately rigid: + * + * - Scheme: `tool-output` + * - Authority: empty (the `///` makes pathname carry the callId) + * - Path: `/{callId}` — preserves case so provider-assigned callIds with + * uppercase letters survive parsing + * - Query and fragment: rejected + * + * Any deviation (different scheme, missing or non-empty hostname, extra path + * segments, search string, or fragment) throws. Missing blobs throw. + * `BlobReader` never accepts a filesystem path; the agent has no direct view + * of the context store's working tree. + */ +export interface BlobReader { + /** + * Resolve `uri` to the underlying blob bytes. Throws if the URI is not a + * well-formed `tool-output:///{callId}` reference or if no blob exists for + * the extracted callId. + */ + read(uri: string): Promise; +} + +/** Source for blob bytes used by `createBlobReader`. */ +export interface BlobSource { + readBlob(key: string, signal?: AbortSignal): Promise; +} + +/** + * Parse a `tool-output:///{callId}` URI and return the callId. Throws on any + * deviation from the documented shape: wrong scheme, non-empty authority, + * missing or extra path components, search string, or fragment. + * + * The two-slash form `tool-output://abc` is rejected because the URL parser + * lowercases the hostname, which silently corrupts provider-assigned callIds + * that contain uppercase letters. The three-slash form puts the callId in + * `pathname`, where case is preserved. + */ +export function parseToolOutputURI(uri: string): string { + let parsed: URL; + try { + parsed = new URL(uri); + } catch (cause) { + throw new Error(`invalid tool-output URI: ${uri}`, { cause }); + } + if (parsed.protocol !== "tool-output:") { + throw new Error( + `invalid tool-output URI scheme: expected "tool-output:", got "${parsed.protocol}"`, + ); + } + if (parsed.hostname !== "") { + throw new Error( + `invalid tool-output URI: authority must be empty (use the form tool-output:///{callId}), got "${parsed.hostname}"`, + ); + } + if (parsed.search !== "") { + throw new Error( + `invalid tool-output URI: query string is not allowed, got "${parsed.search}"`, + ); + } + if (parsed.hash !== "") { + throw new Error( + `invalid tool-output URI: fragment is not allowed, got "${parsed.hash}"`, + ); + } + const path = parsed.pathname; + if (!path.startsWith("/")) { + throw new Error(`invalid tool-output URI: empty path: ${uri}`); + } + const callId = path.slice(1); + if (callId === "") { + throw new Error(`invalid tool-output URI: missing callId: ${uri}`); + } + if (callId.includes("/")) { + throw new Error( + `invalid tool-output URI: path must contain a single callId segment, got "${callId}"`, + ); + } + return callId; +} + +/** + * Construct a `BlobReader` that resolves `tool-output:///{callId}` URIs by + * delegating to `source.readBlob(callId)`. The most common source is a + * `ContextStore` (Phase 2 added `readBlob` to that interface), but any object + * implementing `BlobSource` works — this keeps tests trivial. + * + * URI parsing is performed in this layer; the source only ever sees the + * extracted callId. Missing blobs surface as whatever error the source + * raises (`ContextStore.readBlob` already throws for unknown keys). + */ +export function createBlobReader(source: BlobSource): BlobReader { + return { + async read(uri: string): Promise { + const callId = parseToolOutputURI(uri); + return source.readBlob(callId); + }, + }; +} + +// --------------------------------------------------------------------------- +// Abort Reasons (INFERENCE.md § Abort Handling) +// --------------------------------------------------------------------------- + +/** + * Reason codes for the `abort` reactor event. The reason determines the + * appropriate cleanup action. + * + * (INFERENCE.md § Abort Handling › Abort Reasons) + */ +export const AbortReason = type.enumerated( + "user_disconnect", + "wallet_exhaustion", + "admin_kill", + "session_timeout", + "credential_revocation", +); +export type AbortReason = typeof AbortReason.infer; + +// --------------------------------------------------------------------------- +// Inference Source (INFERENCE.md § Providers) +// --------------------------------------------------------------------------- + +/** + * Model-bound default knobs for an inference source. Per-call + * `InferenceOptions.X` overrides `defaults.X`; the merge happens once at + * the top of `runInference` before the adapter sees anything. New fields + * land here as separately-scoped issues. + */ +export const InferenceSourceDefaults = type({ + "maxTokens?": "number", + // A bag of provider-native knobs the caller wants merged into the + // outbound request body (Anthropic's `metadata.user_id`, + // OpenAI's `user`, Gemini's `safetySettings`, etc.). Adapters that + // recognize keys translate; unrecognized keys are passed through or + // dropped per the adapter's documented behavior. The merge into + // per-call `InferenceOptions.providerOptions` is shallow — a per- + // call providerOptions object wholesale replaces the source-bound + // one, it does not deep-merge per key. + "providerOptions?": "Record", +}); +export type InferenceSourceDefaults = typeof InferenceSourceDefaults.infer; + +/** + * A specific (provider, model) bundle the agent runtime can route to. + * Carries wire reachability, credentials, the model identity at the + * provider, and the model-bound default knobs. + * + * `id` is the catalog offering's primary key, set by the resolver from the + * matched offering. It is the routing key used by `AgentConfig.defaultSource` + * and `Agent.setSource`. + * + * Multi-model providers become multiple sources — `model` is part of the + * identity, not an optional override. + * + * `capabilities` is carried for the selection-policy layer (the model + * selector consumes it). The runtime ignores it; populating the field + * later is not a wire-format change. + * + * `quirks` is the opaque per-deployment bag of provider-specific adapter + * accommodations. The harness reads it once, at adapter instantiation, and + * passes it to `AdapterRegistry.resolve` as a sibling of the slim + * `LastCycleSource` — quirks are deliberately kept off `LastCycleSource`, + * which rides on every usage event. The field is present-and-populated or + * absent; it is never `null`. A source row with no quirks stores SQL `NULL`, + * and the catalog resolver translates that absence into an omitted key here, + * so downstream code sees `undefined`, never `null`. + * + * (INFERENCE.md § Providers) + */ +export const InferenceSource = type({ + id: "string", + provider: "string", + baseURL: "string", + apiKey: "string", + model: "string", + "defaults?": InferenceSourceDefaults, + "capabilities?": "string[]", + "quirks?": "Record", +}); +export type InferenceSource = typeof InferenceSource.infer; + +/** + * Replace every field on `active` with the corresponding field from + * `next`, in place. Optional fields (`defaults`, `capabilities`, + * `quirks`) are `delete`d from `active` when absent on `next` so the + * swap is exact — no stale value from a previous rotation can survive. + * + * Used by both the agent's source registry and the harness's source + * hot-swap path to mutate the single shared `InferenceSource` object the + * reactor reads lazily at the start of each inference call. Putting the + * field list in one place means the next field added to + * `InferenceSource` only has to be remembered here. + */ +export function applyInferenceSourceFields( + active: InferenceSource, + next: InferenceSource, +): void { + active.id = next.id; + active.provider = next.provider; + active.baseURL = next.baseURL; + active.apiKey = next.apiKey; + active.model = next.model; + if (next.defaults !== undefined) { + active.defaults = next.defaults; + } else { + delete active.defaults; + } + if (next.capabilities !== undefined) { + active.capabilities = next.capabilities; + } else { + delete active.capabilities; + } + if (next.quirks !== undefined) { + active.quirks = next.quirks; + } else { + delete active.quirks; + } + + // Compile-time exhaustiveness check. `Required<>` forces optional + // keys to also be required in the guard — so a future optional field + // (e.g. `region?: string`) added to `InferenceSource` without being + // handled above is flagged by TypeScript, not silently dropped. + const _handled: { readonly [K in keyof Required]: true } = { + id: true, + provider: true, + baseURL: true, + apiKey: true, + model: true, + defaults: true, + capabilities: true, + quirks: true, + }; + void _handled; +} + +/** + * Outcome of a `RetryPolicy` consultation. Either abort the call + * (surface the most recent `inference.error` to the caller), or retry + * after `delayMs` milliseconds, measured against the harness Scheduler. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type RetryDecision = + | { kind: "abort" } + | { kind: "retry"; delayMs: number }; + +/** + * Context supplied to a `RetryPolicy` each time an attempt produces an + * `inference.error`. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type RetrySituation = { + /** The classified error the most recent attempt produced. */ + readonly error: InferenceError; + /** + * 1-indexed attempt counter. The first failure has `attempt: 1`; + * the second failure (after one retry) has `attempt: 2`; and so on. + */ + readonly attempt: number; + /** + * Milliseconds since the *first* attempt of this call started, + * measured via the harness `Scheduler.now()`. The default Scheduler + * uses `performance.now()` (sub-millisecond resolution), so the + * value may be fractional; virtual-clock test schedulers report + * integer virtual time. Both are valid; policies that compare + * against integer thresholds should `Math.floor` if they need that. + */ + readonly elapsedMs: number; +}; + +/** + * Per-call retry policy. The harness invokes the policy once per + * `inference.error` an attempt produces, in 1-indexed attempt order. + * Returning `{ kind: "abort" }` ends the call by surfacing the most + * recent error to the caller; returning `{ kind: "retry", delayMs }` + * causes the harness to discard the failed attempt's events, sleep + * `delayMs` milliseconds against the Scheduler, and re-issue the + * underlying HTTP request with the identical body. The policy may be + * async; the harness awaits the returned `Promise` if + * it is a thenable. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type RetryPolicy = ( + situation: RetrySituation, +) => RetryDecision | Promise; + +/** + * Options for a single inference call. Override the defaults from the agent + * configuration on a per-call basis. + * + * (INFERENCE.md § Providers › Streaming Harness) + */ +export type InferenceOptions = { + maxTokens?: number; + temperature?: number; + thinking?: { enabled: boolean; budgetTokens?: number }; + systemPrompt?: string; + tools?: ToolDefinition[]; + /** + * Modalities the caller wants the model to emit. Adapters translate + * to the provider-native shape (Gemini's + * `generationConfig.responseModalities` accepts `"TEXT"` / `"IMAGE"` + * uppercase; see `packages/inference-discovery-google-genai/sessions/ + * google-genai/gemini-2.5-flash-image/image-output/exchanges/0/request.json` + * for the captured shape). Providers that do not expose a modality + * switch ignore the + * field. When omitted the provider's default modalities apply. + */ + responseModalities?: ("text" | "image" | "audio")[]; + /** + * Structured-output constraint. Asks the model to produce text, free- + * form JSON, or JSON conforming to a specific schema. Adapters + * translate to the provider-native wire shape: + * + * - **OpenAI** (`response_format`): + * - `text` → `{ type: "text" }` + * - `json` → `{ type: "json_object" }` + * - `json-schema` → `{ type: "json_schema", json_schema: { name, schema, strict } }` + * When the model declines in strict mode, the wire emits + * `delta.refusal` chunks; the adapter surfaces them as + * `inference.refusal.delta` events and a final `RefusalBlock` in + * the assistant turn's `content[]`. + * - **Google GenAI** (`generationConfig`): + * - `text` → no constraint (default). + * - `json` → `{ responseMimeType: "application/json" }`. + * - `json-schema` → `{ responseMimeType: "application/json", responseSchema: }`. + * `name` and `strict` are OpenAI-specific and have no Gemini + * counterpart; adapters ignore them. Gemini enforces a subset of + * JSON Schema (no `oneOf`, limited `pattern`, no `$ref`, etc.) — + * the adapter forwards the schema verbatim and surfaces Gemini's + * HTTP error if the subset is violated. + * - **Anthropic**: no native structured-output API. + * - `text` is a no-op (the default). + * - `json` and `json-schema` throw at the adapter boundary; there + * is no shim that synthesizes a tool to extract structured + * output. + * + * When omitted the provider's default applies (typically free-form + * text). + */ + responseFormat?: + | { kind: "text" } + | { kind: "json" } + | { + kind: "json-schema"; + name: string; + schema: unknown; + strict?: boolean; + }; + /** + * A bag of provider-native knobs the adapter merges into the outbound + * request body. Primary home is `InferenceSourceDefaults.providerOptions` + * (model-bound); this field exists for per-call overrides through the + * standard merge precedence at the top of `runInference`. The merge is + * shallow: a per-call providerOptions object wholesale replaces the + * source-bound one, it does not deep-merge per key. + */ + providerOptions?: Record; + /** + * Per-call inactivity timeout in milliseconds. If the harness yields no + * event (other than `inference.start`) for this many ms, the underlying + * fetch is aborted and the call ends with `inference.error` of category + * `"timeout"`. Default 120_000 (2 min). Tune higher for reasoning models + * that exhibit long silent-thinking stretches between token bursts; tune + * lower to fail fast. `0` arms the timer to fire on the next tick (a + * "fail-fast even if the fetch is instant" mode useful in tests). + */ + inactivityTimeoutMs?: number; + /** + * Per-call total wall-clock cap in milliseconds. Starts at fetch. + * Default 600_000 (10 min). Backstop for streams that keep emitting + * forever without terminating. Same error category as `inactivityTimeoutMs`. + * `0` arms the timer to fire on the next tick. + */ + totalTimeoutMs?: number; + /** + * Per-call mechanical retry policy. Consulted once per attempt that + * ends in `inference.error`; see `RetryPolicy` for the contract. If + * omitted, a built-in default policy is applied. + */ + retryPolicy?: RetryPolicy; +}; + +// --------------------------------------------------------------------------- +// Context Store (INFERENCE.md § Context Management › Context Store, +// ARCHITECTURE.md § Change History) +// --------------------------------------------------------------------------- + +/** + * A named commit point in the context store. Corresponds to a git commit. + * + * (ARCHITECTURE.md § Change History › Named Checkpoints) + */ +export type ContextCommit = { + hash: string; + message: string; + timestamp: number; + parentHash?: string; +}; + +/** + * The state of an active connector thread. The connector is one durable + * thread per agent; participants accumulate as they speak. Persisted + * alongside the conversation context so the thread survives sidecar + * restarts. + * + * `replyTo` is the most recent speaker — the primary recipient (`to`) + * on the next outbound reply. `cc` is every other participant who has + * spoken on the thread, deduplicated, in arrival order — they ride as + * `cc` on the next outbound reply so everyone stays in the loop. + * `subject` is set when the thread starts and preserved for its life. + * + * Defined as an arktype so the wire layer (sidecar↔hub frames) and + * other parsing boundaries can validate snapshots without + * re-declaring the shape. + */ +export const ConnectorThreadState = type({ + threadRoot: "string", + lastMessageId: "string", + replyTo: "string", + cc: "string[]", + "subject?": "string", +}); +export type ConnectorThreadState = typeof ConnectorThreadState.infer; + +/** + * The context store interface. Implementations back the store with git + * (filesystem, in-memory, or virtual) depending on the execution environment. + * The reactor accepts any implementation that satisfies this interface. + * + * The store holds the turn history and reactor metadata. Forking creates + * a git branch. Compaction commits the compacted history. + * + * (INFERENCE.md § Context Management › Context Store) + */ +export interface ContextStore { + /** + * Load the current turn history and reactor metadata from the store. + * Called during reactor initialization. + */ + load(signal?: AbortSignal): Promise<{ + turns: ConversationTurn[]; + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + connectorState: ConnectorThreadState | null; + }>; + + /** + * Buffer connector thread state for the next commit. The harness calls + * this before each checkpoint so that connector state is persisted + * atomically with the conversation context. + */ + setConnectorState(state: ConnectorThreadState | null): void; + + /** + * Commit whatever currently lives in the working tree, using the supplied + * commit message. The reactor's per-cycle checkpoint routes through this + * overload after writing the per-cycle files via `writeTurns`, + * `writePrompt`, `writeResponse`, `writeManifest`, and any `writeBlob` + * calls produced by transforms. + */ + commit( + options: { message: string }, + signal?: AbortSignal, + ): Promise; + + /** + * Create a branch for a fork operation. The branch starts from the current + * HEAD commit. + */ + branch(name: string, signal?: AbortSignal): Promise; + + /** + * List recent commits. Used by the agent's history query tools. + */ + log(limit?: number, signal?: AbortSignal): Promise; + + /** + * Read the turn history at a specific commit hash. Used for history + * inspection and rollback. + */ + readAt(hash: string, signal?: AbortSignal): Promise; + + /** + * Write an opaque blob to the working tree under `tool-output/`. Used by + * `ToolResultTransform`s that spill oversized payloads out of the inline + * conversation. The file is staged at the next `commit({ message })`. + * + * `key` is sanitized for filesystem safety; callers should pass the tool + * call id. `contentType` selects a file extension when known. + */ + writeBlob( + key: string, + bytes: Uint8Array, + contentType?: string, + signal?: AbortSignal, + ): Promise; + + /** + * Read a blob previously written via `writeBlob`. Throws if no blob with + * that key exists. + */ + readBlob(key: string, signal?: AbortSignal): Promise; + + /** + * Overwrite `prompt.jsonl` with the materialized prompt for the current + * inference cycle. One `ConversationTurn` per line. Staged at the next + * `commit({ message })`. + */ + writePrompt(turns: ConversationTurn[], signal?: AbortSignal): Promise; + + /** + * Overwrite `response.jsonl` with the assistant turn returned for the + * current cycle. Single-line JSONL for consistency with the per-cycle file + * conventions. Staged at the next `commit({ message })`. + */ + writeResponse(turn: AssistantTurn, signal?: AbortSignal): Promise; + + /** + * Overwrite `manifest.jsonl` with the ordered transform records produced + * for the current cycle. One `TransformRecord` per line. Staged at the + * next `commit({ message })`. + */ + writeManifest( + records: TransformRecord[], + signal?: AbortSignal, + ): Promise; + + /** + * Overwrite `turns.jsonl` with the durable conversation history. One + * `ConversationTurn` per line. Staged at the next `commit({ message })`. + */ + writeTurns(turns: ConversationTurn[], signal?: AbortSignal): Promise; + + /** + * Overwrite `metadata.json` with non-turn-shaped reactor state needed for + * restart: pending async operations and cumulative token usage. The store + * combines this with the most recently buffered connector state (from + * `setConnectorState`) and writes the merged payload. Staged at the next + * `commit({ message })`. + */ + writeMetadata( + metadata: { + pendingOperations: PendingOperation[]; + tokenUsage: TokenUsage; + }, + signal?: AbortSignal, + ): Promise; + + /** + * Read manifest entries from the most recent `limit` commits that contain + * a `manifest.jsonl`. Newest commit first; records within a commit are + * returned in their natural in-file order (chronological per-cycle). + */ + readManifestHistory( + limit: number, + signal?: AbortSignal, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Audit Store (INTR-4 § Audit Trail) +// --------------------------------------------------------------------------- + +/** + * Persistent store for tool invocation audit records. Separated from + * ContextStore so the audit capability is opt-in at the composition + * layer. The isogit implementation writes audit records as individual + * JSON files in the same git repo used for context storage. + */ +export interface AuditStore { + /** + * Persist a batch of audit records. Called at checkpoint boundaries + * with all records accumulated since the last checkpoint. + */ + commitAudit(records: AuditRecord[], signal?: AbortSignal): Promise; + + /** + * Load audit records for a session. Returns all records matching + * the given sessionId, ordered by seq. + */ + loadAudit(sessionId: string, signal?: AbortSignal): Promise; + + /** + * Persist a batch of error records. Called at checkpoint boundaries + * and shutdown with all error records accumulated since the last flush. + */ + commitErrors(records: ErrorRecord[], signal?: AbortSignal): Promise; +} + +// --------------------------------------------------------------------------- +// Agent / Harness Configuration (ARCHITECTURE.md § Agent Harness) +// --------------------------------------------------------------------------- + +/** + * Configured tool definition exposed to the model. The harness registers + * available tools; the reactor passes this list to the inference provider as + * part of each request. + * + * (ARCHITECTURE.md § Agent Harness › Tools) + */ +export const ToolDefinition = type({ + name: "string", + description: "string", + inputSchema: "Record", +}); +export type ToolDefinition = typeof ToolDefinition.infer; + +/** + * Agent harness configuration. Assembled from the agent definition package + * and capability grants during harness initialization. + * + * `principalId` is the agent's principal in the hub's authorization model. + * The sidecar needs it to reconstruct the in-memory grant store on restart + * (the store's `collectGrants` filters by principal). + * + * `grants` uses `WireGrantRule` because this type arrives over JSON where + * `GrantRule.expiresAt` is serialized as a string. The wire validator + * coerces strings back to Date instances. + * + * (ARCHITECTURE.md § Agent Harness) + */ +export const HarnessConfig = type({ + sessionId: "string", + agentId: "string", + tenantId: "string", + principalId: "string", + agentAddress: "string", + systemPrompt: "string", + tools: ToolDefinition.array(), + grants: WireGrantRule.array(), + sources: InferenceSource.array(), + defaultSource: "string", + "sessionChannelEnabled?": "boolean", +}); +export type HarnessConfig = typeof HarnessConfig.infer; diff --git a/vendor/intx-types/src/sessions.test.ts b/vendor/intx-types/src/sessions.test.ts new file mode 100644 index 000000000..6800d6c96 --- /dev/null +++ b/vendor/intx-types/src/sessions.test.ts @@ -0,0 +1,112 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; +import { SendMessage, AttachmentError } from "./sessions"; +import { base64Encode } from "./base64"; + +const okData = base64Encode(new TextEncoder().encode("hello world")); + +describe("SendMessage attachments schema", () => { + test("accepts a message with no attachments", () => { + const result = SendMessage({ content: "hi" }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an attachment with mimeType, base64 data, and name", () => { + const result = SendMessage({ + content: "see attached", + attachments: [{ mimeType: "image/png", data: okData, name: "shot.png" }], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an attachment without the optional name", () => { + const result = SendMessage({ + content: "", + attachments: [{ mimeType: "image/png", data: okData }], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an attachment missing mimeType", () => { + const result = SendMessage({ + content: "x", + attachments: [{ data: okData }], + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an attachment missing data", () => { + const result = SendMessage({ + content: "x", + attachments: [{ mimeType: "image/png" }], + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts any string data; base64 validity is the route's job", () => { + // The schema only checks that data is a string. The route boundary + // decodes it and emits malformed_base64 when it is not valid base64. + const result = SendMessage({ + content: "x", + attachments: [{ mimeType: "image/png", data: "not base64!!!" }], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects the removed type field (closed attachment schema)", () => { + const result = SendMessage({ + content: "x", + attachments: [{ mimeType: "image/png", data: okData, type: "image" }], + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("AttachmentError schema", () => { + test("accepts each structured error variant", () => { + const variants = [ + { + code: "oversize_attachment", + message: "too big", + attachmentIndex: 0, + byteLength: 99, + limitBytes: 10, + }, + { + code: "disallowed_mime_type", + message: "nope", + attachmentIndex: 1, + mimeType: "image/tiff", + }, + { code: "malformed_base64", message: "bad", attachmentIndex: 2 }, + { + code: "invalid_attachment_name", + message: "bad name", + attachmentIndex: 3, + }, + { + code: "oversize_total", + message: "too much", + totalBytes: 99, + limitBytes: 30, + }, + ]; + for (const variant of variants) { + expect(AttachmentError(variant) instanceof type.errors).toBe(false); + } + }); + + test("rejects an unknown code", () => { + const result = AttachmentError({ code: "nope", message: "x" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a variant missing its structured fields", () => { + const result = AttachmentError({ + code: "oversize_attachment", + message: "x", + attachmentIndex: 0, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/sessions.ts b/vendor/intx-types/src/sessions.ts new file mode 100644 index 000000000..856195da8 --- /dev/null +++ b/vendor/intx-types/src/sessions.ts @@ -0,0 +1,151 @@ +import { type } from "arktype"; + +export const CreateSession = type({ + agentId: "string", + "invokerCapabilities?": type({ + resource: "string", + action: "string", + "conditions?": "Record | null", + }).array(), +}); + +export const SessionResponse = type({ + id: "string", + tenantId: "string", + agentId: "string", + principalId: "string", + status: type("'idle' | 'ending' | 'ended'").describe( + "Persisted lifecycle state of the session: `idle` (open, awaiting work), `ending` (teardown in progress), or `ended` (closed).", + ), + createdAt: "string", + updatedAt: "string", + "lastActivityAt?": "string | null", +}); + +// Runtime operational status of an active session. The harness retries +// internally and does not surface retry state to the hub, so the retry +// variant is omitted until the event protocol supports it. +export const SessionStatus = type({ + status: type("'idle' | 'busy' | 'waiting_approval'").describe( + "Runtime operational state of an active session, distinct from its persisted lifecycle state: `idle` (ready), `busy` (processing a turn), or `waiting_approval` (blocked on an interactive approval before a tool call can proceed).", + ), +}); +export type SessionStatus = typeof SessionStatus.infer; + +// The schema validates structure only: a required mimeType, a required +// string `data` carrying base64-encoded bytes, an optional name, and no +// other keys. base64 validity, the MIME allowlist, and size limits are +// enforced at the route boundary so it can emit ordered, per-index +// structured errors (malformed_base64, disallowed_mime_type, oversize_*) +// that an all-or-nothing schema validator cannot produce. +export const SendMessage = type({ + content: "string", + "attachments?": type({ + mimeType: "string", + data: "string", + "name?": "string", + }) + .onUndeclaredKey("reject") + .array(), +}); + +export const MailResponse = type({ + id: "string", + sessionId: type("string").describe( + "Internal session channel identifier, not a user-facing session resource.", + ), + instanceId: "string | null", + direction: type("'inbound' | 'outbound'").describe( + "Whether the message was sent to the agent (`inbound`) or emitted by the agent (`outbound`).", + ), + status: type("'pending' | 'delivered'").describe( + "Delivery state of the mail: `pending` (accepted, not yet dispatched to the running agent) or `delivered`.", + ), + receivedAt: "string", + from: type({ + name: "string | null", + email: "string", + }).array(), + to: type({ + name: "string | null", + email: "string", + }).array(), + subject: "string | null", + sentAt: "string | null", + bodyValues: "Record", + textBody: type({ + partId: "string", + type: "string", + }).array(), + htmlBody: type({ + partId: "string", + type: "string", + }).array(), + attachments: type({ + blobId: "string", + name: "string | null", + type: "string", + size: "number", + }).array(), + headers: "Record", +}); +export type MailResponse = typeof MailResponse.infer; + +// Structured attachment-rejection errors returned by POST /:instanceId/mail. +// Each variant carries a machine-actionable `code` plus the fields a client +// needs to locate and explain the rejection, alongside a human-readable +// `message`. This is the wire contract for the route's attachment 400s; the +// route handler is the single producer. +export const AttachmentError = type({ + code: "'oversize_attachment'", + message: "string", + attachmentIndex: "number", + byteLength: "number", + limitBytes: "number", +}) + .or({ + code: "'disallowed_mime_type'", + message: "string", + attachmentIndex: "number", + mimeType: "string", + }) + .or({ + code: "'invalid_attachment_name'", + message: "string", + attachmentIndex: "number", + }) + .or({ + code: "'malformed_base64'", + message: "string", + attachmentIndex: "number", + }) + .or({ + code: "'oversize_total'", + message: "string", + totalBytes: "number", + limitBytes: "number", + }); +export type AttachmentError = typeof AttachmentError.infer; + +export const AttachmentErrorResponse = type({ error: AttachmentError }); +export type AttachmentErrorResponse = typeof AttachmentErrorResponse.infer; + +export const InferenceTurnResponse = type({ + id: "string", + sessionId: type("string").describe( + "Internal session channel identifier, not a user-facing session resource.", + ), + instanceId: "string", + model: "string", + status: "'running' | 'completed' | 'failed'", + startedAt: "string", + endedAt: "string | null", + parts: type({ + id: "string", + type: "'text' | 'reasoning' | 'tool' | 'file' | 'error' | 'step-start' | 'step-finish' | 'snapshot' | 'patch'", + "content?": "string | null", + "metadata?": "Record | null", + ordinal: "number", + }).array(), +}); +export type InferenceTurnResponse = typeof InferenceTurnResponse.infer; diff --git a/vendor/intx-types/src/sidecar.test.ts b/vendor/intx-types/src/sidecar.test.ts new file mode 100644 index 000000000..e573c13f7 --- /dev/null +++ b/vendor/intx-types/src/sidecar.test.ts @@ -0,0 +1,235 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; +import { APPROVAL_SNAPSHOT_MAX_BYTES } from "./runtime"; +import { + AgentDeployFrame, + DeployApplyErrorCategory, + SidecarFrame, + SignalCorrelationRegisterFrame, + SourcesUpdateFrame, +} from "./sidecar"; + +describe("DeployApplyErrorCategory", () => { + const allCategories = [ + "tarball.missing", + "integrity.mismatch", + "registry.fetch.failed", + "registry.unknown", + "registry.auth.failed", + "tarball.extract.failed", + "manifest.invalid", + "package.entry.missing", + "package.entry.invalid", + "factory.construct.failed", + "tool.name.duplicate", + "apply.swap.failed", + "apply.previous-rotation.failed", + ] as const; + + for (const category of allCategories) { + test(`accepts ${category}`, () => { + const result = DeployApplyErrorCategory(category); + expect(result instanceof type.errors).toBe(false); + }); + } + + test("rejects an unknown category", () => { + const result = DeployApplyErrorCategory("network.timeout"); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("AgentDeployFrame", () => { + const baseConfig = { + sessionId: "ses_1", + agentId: "agt_1", + tenantId: "ten_1", + principalId: "pri_1", + agentAddress: "agt_1@example.test", + systemPrompt: "system prompt", + tools: [], + grants: [], + sources: [ + { + id: "src_default", + provider: "openai", + baseURL: "https://api.openai.test", + apiKey: "sk-test", + model: "gpt-test", + }, + ], + defaultSource: "src_default", + }; + + const trivialFrame = { + type: "agent.deploy" as const, + agentAddress: "agt_1@example.test", + agentId: "agt_1", + config: baseConfig, + hubPublicKey: "hub_pubkey_hex", + }; + + const stepSource = { + id: "src_step", + provider: "openai", + baseURL: "https://api.openai.test", + apiKey: "sk-step", + model: "gpt-step", + }; + + test("accepts the existing trivial-shape frame (no workflow field)", () => { + const result = AgentDeployFrame(trivialFrame); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a multi-step frame with matching definition and sources", () => { + const result = AgentDeployFrame({ + ...trivialFrame, + workflow: { + definition: { + id: "wf_demo", + triggers: [{ type: "manual" }], + stepOrder: ["plan", "act"], + steps: { plan: {}, act: {} }, + }, + sources: { plan: [stepSource], act: [stepSource] }, + }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a frame whose workflow.definition is present without sources", () => { + const result = AgentDeployFrame({ + ...trivialFrame, + workflow: { + definition: { + id: "wf_demo", + triggers: [{ type: "manual" }], + stepOrder: ["plan"], + steps: { plan: {} }, + }, + }, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a frame whose stepOrder names a step missing from sources", () => { + const result = AgentDeployFrame({ + ...trivialFrame, + workflow: { + definition: { + id: "wf_demo", + triggers: [{ type: "manual" }], + stepOrder: ["plan", "act"], + steps: { plan: {}, act: {} }, + }, + sources: { plan: [stepSource] }, + }, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a frame whose workflow.definition is missing triggers", () => { + // The wire validator must require `triggers` because the sidecar + // deploy router serializes `definition` verbatim into + // `workflow.json` and the workflow-process child re-validates the + // envelope (`workflowDefinitionEnvelopeSchema`) which requires it. + const result = AgentDeployFrame({ + ...trivialFrame, + workflow: { + definition: { + id: "wf_demo", + stepOrder: ["plan"], + steps: { plan: {} }, + }, + sources: { plan: [stepSource] }, + }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("SourcesUpdateFrame", () => { + const source = { + id: "src_a", + provider: "openai", + baseURL: "https://api.openai.test", + apiKey: "sk-a", + model: "gpt-a", + }; + const base = { + type: "sources.update" as const, + requestId: "req_1", + agentAddress: "agt_1@example.test", + defaultSource: "src_a", + }; + + test("accepts a frame with a non-empty sources list", () => { + const result = SourcesUpdateFrame({ ...base, sources: [source] }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a frame whose sources list is empty", () => { + // The hub never emits an empty rotation -- `pushInstanceSourceUpdate` + // returns early when there is no head source -- so the boundary + // rejects an empty `sources` rather than accepting a rotation the + // agent could not swap to any live source. + const result = SourcesUpdateFrame({ ...base, sources: [] }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("SignalCorrelationRegisterFrame snapshot requirement", () => { + const base = { + type: "signal.correlation.register", + correlationId: "corr-1", + runId: "run-1", + deploymentId: "dep-1", + agentAddress: "ins_dep@integration.interchange", + kind: "approval", + }; + const snapshot = { + name: "charge_card", + description: "Charge the customer's card", + inputSchema: { type: "object" }, + arguments: { amount: 100 }, + }; + + test("accepts a register frame carrying a snapshot", () => { + const frame = { ...base, snapshot }; + expect(SignalCorrelationRegisterFrame(frame) instanceof type.errors).toBe( + false, + ); + expect(SidecarFrame(frame) instanceof type.errors).toBe(false); + }); + + test("rejects a register frame with no snapshot", () => { + // The ask rail is the only producer and always carries a snapshot, so a + // snapshot-absent frame is malformed at the receive boundary -- it fails + // the union parse and is logged and dropped, never co-written as null. + expect(SignalCorrelationRegisterFrame(base) instanceof type.errors).toBe( + true, + ); + expect(SidecarFrame(base) instanceof type.errors).toBe(true); + }); + + test("rejects a register frame whose snapshot exceeds the size cap", () => { + // The snapshot crosses the sidecar->hub boundary as a + // `BoundedApprovalSnapshot`, so an oversized one -- here an inputSchema + // padded past the byte cap -- fails the frame parse and is dropped rather + // than co-written onto an approval row. Only the pad pushes it over; every + // other field is the valid baseline, so the cap is the sole reason for + // rejection. + const frame = { + ...base, + snapshot: { + ...snapshot, + inputSchema: { pad: "a".repeat(APPROVAL_SNAPSHOT_MAX_BYTES) }, + }, + }; + expect(SignalCorrelationRegisterFrame(frame) instanceof type.errors).toBe( + true, + ); + expect(SidecarFrame(frame) instanceof type.errors).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/sidecar.ts b/vendor/intx-types/src/sidecar.ts new file mode 100644 index 000000000..4be7c3dc8 --- /dev/null +++ b/vendor/intx-types/src/sidecar.ts @@ -0,0 +1,811 @@ +// Websocket wire protocol for hub↔sidecar communication. +// +// One websocket connection per sidecar↔hub pair. All traffic is multiplexed +// as JSON frames with a `type` discriminator. The sidecar initiates the +// connection; the hub is the server. +// +// Mail bytes are base64-encoded in JSON frames. Binary frames would be more +// efficient but JSON is simpler to debug and inspect. + +import { type } from "arktype"; +import { WireGrantRule } from "./grant-wire"; +import { + BoundedApprovalSnapshot, + ConnectorThreadState, + HarnessConfig, + InferenceEvent, + InferenceSource, +} from "./runtime"; +import { SignalKind } from "./signals"; + +// --------------------------------------------------------------------------- +// Sidecar → Hub +// --------------------------------------------------------------------------- + +/** + * Sent on first connect when the sidecar has no existing agents in its data + * directory. Identifies the sidecar and declares it ready to receive + * agent.deploy frames. + */ +export const RegisterFrame = type({ + type: "'register'", + sidecarId: "string", + token: "string", + agentAddresses: "string[]", +}); +export type RegisterFrame = typeof RegisterFrame.infer; + +/** + * Sent on connect when the sidecar has agent repositories or deployments + * from a previous run. Lists the addresses it can serve, triggering the + * challenge/response ownership-verification flow for every one of them -- + * launched agents and workflow deployments alike, so both are proven, not + * routed on trust. + */ +export const ReconnectFrame = type({ + type: "'reconnect'", + sidecarId: "string", + token: "string", + agentAddresses: "string[]", + "deployRefs?": "Record", +}); +export type ReconnectFrame = typeof ReconnectFrame.infer; + +/** + * Response to a challenge frame. Contains a signature per agent address + * proving the sidecar holds the private key. Each signature is computed + * over `nonce || utf8(agentAddress)`. + */ +export const ChallengeResponseFrame = type({ + type: "'challenge.response'", + responses: type({ address: "string", signature: "string" }).array(), +}); +export type ChallengeResponseFrame = typeof ChallengeResponseFrame.infer; + +/** + * Acknowledges a successful agent deployment. Includes the agent's Ed25519 + * public key (hex-encoded) so the hub can verify ownership on reconnect. + */ +export const AgentDeployAckFrame = type({ + type: "'agent.deploy.ack'", + agentAddress: "string", + publicKey: "string", +}); +export type AgentDeployAckFrame = typeof AgentDeployAckFrame.infer; + +/** + * Reports a failed agent deployment. + */ +export const AgentErrorFrame = type({ + type: "'agent.error'", + agentAddress: "string", + error: "string", +}); +export type AgentErrorFrame = typeof AgentErrorFrame.infer; + +/** + * A message from a local agent. When `delivered` is absent or false the hub + * should route the message to its recipients. When `delivered` is true the + * message was already delivered locally and is forwarded for audit/projection + * only — the hub must not re-route it. + * + * Structured metadata (senderAddress, messageId, to, cc) is available for + * audit and projection purposes without parsing the raw MIME bytes. + */ +export const MailOutboundFrame = type({ + type: "'mail.outbound'", + rawMessage: "string", + recipients: "string[]", + "senderAddress?": "string", + "sessionId?": "string", + "messageId?": "string", + "to?": "string[]", + "cc?": "string[]", + "delivered?": "boolean", +}); +export type MailOutboundFrame = typeof MailOutboundFrame.infer; + +/** + * An InferenceEvent from the reactor, forwarded for UI consumption. Tagged + * with the agent address so the hub can route to the correct UI client. + */ +export const AgentEventFrame = type({ + type: "'agent.event'", + agentAddress: "string", + sessionId: "string", + event: InferenceEvent, +}); +export type AgentEventFrame = typeof AgentEventFrame.infer; + +/** + * Notifies the hub that the agent's connector-thread state has changed. + * The sidecar emits this when the harness's connector router commits a + * start/continue decision, when an outbound reply advances the + * lastMessageId, and when load-time restore brings persisted state into + * memory. The hub uses the cached state to set threading headers on + * user-originated mail so the harness routes it as `continue` rather + * than `passthrough`. + * + * `connectorState` is `null` when no active thread exists. + */ +export const ConnectorStateChangedFrame = type({ + type: "'connector.state.changed'", + agentAddress: "string", + connectorState: ConnectorThreadState.or("null"), +}); +export type ConnectorStateChangedFrame = + typeof ConnectorStateChangedFrame.infer; + +/** + * Keepalive ping sent by the sidecar. The hub responds with a pong frame. + * If the hub stops receiving pings, it considers the sidecar dead. + */ +export const PingFrame = type({ type: "'ping'" }); +export type PingFrame = typeof PingFrame.infer; + +/** + * Acknowledges a request from the hub (sources.update). + */ +export const SessionAckFrame = type({ + type: "'session.ack'", + requestId: "string", +}); +export type SessionAckFrame = typeof SessionAckFrame.infer; + +/** + * Reports an error processing a hub request. + */ +export const SessionErrorFrame = type({ + type: "'session.error'", + requestId: "string", + error: "string", +}); +export type SessionErrorFrame = typeof SessionErrorFrame.infer; + +/** + * Acknowledges that an agent has been fully undeployed: harness stopped, + * state pushed (best-effort), and directory deleted. + */ +export const AgentUndeployAckFrame = type({ + type: "'agent.undeploy.ack'", + agentAddress: "string", + statePushed: "boolean", +}); +export type AgentUndeployAckFrame = typeof AgentUndeployAckFrame.infer; + +/** + * Registers a control-signal correlation as a workflow agent step suspends. + * The fields on this frame all converge at the sidecar's suspend emit point; + * the hub uses them to co-write the `signal_correlation` routing row and the + * `approval` row in one transaction, so the eventual resolver can route a + * delivered decision back to the parked run and flip its approval. + * + * `signalName` is deliberately NOT on the wire: it is a pure function of + * `correlationId` (`signalName(correlationId)` in `./signals`), so the hub + * computes it rather than trusting a value the sidecar could disagree on. + * `deploymentId` is the workflow deployment the run belongs to; `agentAddress` + * is the deployment's routable address the hub resolves tenancy from. + */ +export const SignalCorrelationRegisterFrame = type({ + type: "'signal.correlation.register'", + correlationId: "string", + runId: "string", + deploymentId: "string", + agentAddress: "string", + kind: SignalKind, + // Approver-facing snapshot of the suspended tool call, size-capped at this + // trust boundary. Required: the ask rail is the only producer of this frame + // and always carries a snapshot, so a snapshot-absent frame fails this parse + // at the receiver (logged and dropped, never co-written as a null row). + snapshot: BoundedApprovalSnapshot, +}); +export type SignalCorrelationRegisterFrame = + typeof SignalCorrelationRegisterFrame.infer; + +// --------------------------------------------------------------------------- +// Hub → Sidecar +// --------------------------------------------------------------------------- + +/** + * Hub acknowledges a `signal.correlation.register`: the routing + approval + * co-write for this correlationId is durable (whether this frame inserted the + * rows or found them already present). It lets the sidecar's link stop + * retrying a register whose frame may have been lost on an open socket or + * evicted from the bounded send queue. Keyed on correlationId alone -- every + * producer of the register (the initial park, the respawn/reconnect re-emit, a + * link retry) carries the same correlationId and drives the same idempotent + * co-write, so the ack asserts the one fact that matters: a row exists for this + * correlation. + */ +export const SignalCorrelationRegisterAckFrame = type({ + type: "'signal.correlation.register.ack'", + agentAddress: "string", + correlationId: "string", +}); +export type SignalCorrelationRegisterAckFrame = + typeof SignalCorrelationRegisterAckFrame.infer; + +/** + * A message to deliver to a local agent's INBOX. The hub routes inbound + * mail (from UI users, from agents on other sidecars) to the correct + * sidecar connection. + * + * `messageId` is the hub-minted id of this delivery, carried so the sidecar + * can acknowledge durable receipt (`mail.inbound.ack`) keyed on the SAME id + * the hub tracks -- no per-side re-derivation. It is the id the hub minted at + * ingress (also the message's `Message-ID` header), so a redelivery replays + * identical bytes and the downstream `RunStarted` dedup (consumedMessageIds) + * makes at-least-once effectively-once. Present only on hub-originated mail + * that participates in the ack/retry handshake (workflow trigger mail, session + * conversation mail); agent-to-agent relayed mail omits it. + */ +export const MailInboundFrame = type({ + type: "'mail.inbound'", + agentAddress: "string", + rawMessage: "string", + "messageId?": "string", +}); +export type MailInboundFrame = typeof MailInboundFrame.infer; + +/** + * Sidecar acknowledges durable receipt of a `mail.inbound`: the message is in + * the agent's on-disk inbox. The hub holds each delivered mail in a pending + * map and retries until this ack lands (or reconnect-redelivers it), so a + * message dropped in the connected/reconnecting window is not silently lost. + * Keyed on the hub-minted `messageId` the `mail.inbound` carried, so the ack + * clears exactly the pending entry it resolves; the ack is only sent AFTER the + * durable inbox write resolves (a non-ack IS the retry signal). At-least-once + * delivery is made effectively-once by the `RunStarted`/signal dedup guards. + */ +export const MailInboundAckFrame = type({ + type: "'mail.inbound.ack'", + agentAddress: "string", + messageId: "string", +}); +export type MailInboundAckFrame = typeof MailInboundAckFrame.infer; + +/** + * Deliver a workflow-run signal to a multi-step deployment's + * supervisor. The hub forwards the frame to the sidecar that hosts the + * deployment named by `agentAddress` (the deployment-level mail + * address). The sidecar's hub-link routes the frame into the matching + * supervisor's `deliverSignal`, which sends a `signal.deliver` control + * IPC frame to the workflow-process child. The child commits the + * `SignalReceived` event through its own substrate -- the single + * writer of the workflow-run repo on the sidecar side -- so the + * pack-push pipeline that propagates the commit to the hub never sees + * a concurrent writer at the same ref. + * + * `signalId` is supplied by the producer so the workflow-run state + * machine's dedup index (`observedSignalIds`) rejects a duplicate + * delivery cleanly; a fresh value per call is the producer's + * responsibility. + */ +export const SignalDeliverFrame = type({ + type: "'signal.deliver'", + agentAddress: "string", + runId: "string", + signalName: "string", + signalId: "string", + payload: "unknown", +}); +export type SignalDeliverFrame = typeof SignalDeliverFrame.infer; + +/** + * Deliver a run's authorization grants to a multi-step deployment's + * supervisor. The hub forwards the frame to the sidecar that hosts the + * deployment named by `agentAddress` (the deployment-level mail + * address). The sidecar's hub-link routes the frame into the matching + * deployment's wiring, which writes the grants to `runs//grants.json` + * inside the deployment's `workflow-run` repo -- sibling to the run's + * `runs//events/` subtree. + * + * `stepGrants` carries the same `WireGrantRule` shape the `agent.deploy` + * frame's `config.grants` ships, so the run's grants ride the same + * validated grant encoding as the deploy-time step grants rather than a + * new one. + */ +export const RunGrantsFrame = type({ + type: "'run.grants'", + agentAddress: "string", + runId: "string", + stepGrants: WireGrantRule.array(), +}); +export type RunGrantsFrame = typeof RunGrantsFrame.infer; + +/** + * Deliver a workflow-host drain control payload to a multi-step + * deployment's supervisor. The hub forwards the frame to the sidecar + * that hosts the deployment named by `agentAddress` (the + * deployment-level mail address). The sidecar's hub-link routes the + * frame into the matching supervisor's `drain`, which sends a `drain` + * control IPC frame to the workflow-process child and arms one + * `drainTimeout` accumulator per in-flight run. Cancel-mode in-flight + * steps abort on the child side as the controller's signal flips; + * wait-mode steps continue. Each accumulator commits a signed + * `CancelRequested{origin: "supervisor-drain"}` against the + * workflow-run repo through the supervisor's substrate when the + * deadline expires. + * + * `deadlineMs` is the wire-level policy hint the child echoes in its + * logs. The supervisor's accumulator is driven by its own bindings' + * `drainTimeoutMs` -- a per-deployment operator setting -- not by this + * value; the wire field exists so the child's log reflects the + * caller's intent. + */ +export const DrainDeliverFrame = type({ + type: "'drain.deliver'", + agentAddress: "string", + deadlineMs: "number", +}); +export type DrainDeliverFrame = typeof DrainDeliverFrame.infer; + +/** + * Workflow projection carried on an `agent.deploy` frame. Its presence + * at the deploy router routes the frame to the workflow deploy path -- + * single- or multi-step, both of which spawn the workflow-process child + * -- as opposed to a per-step provision frame. + * + * `definition` is the wire projection of `WorkflowDefinition` from + * `@intx/workflow`. The arktype validator enforces the structural + * envelope the workflow-process child re-parses on the sidecar after + * materialization (`packages/hub-sessions/src/workflow-kind.ts`'s + * `workflowDefinitionEnvelopeSchema`): `id`, `triggers`, `steps`, + * `stepOrder`, optional `state`. The wire validator MUST require every + * field the envelope requires — the sidecar's deploy router serializes + * `projection.definition` verbatim into `workflow.json` and the child + * rejects a tree missing any envelope-required field. Deeper validation + * of authoring-time primitive shape lives on the workflow definition + * surface in `@intx/workflow`, not on the wire. + * + * `sources` pins an ordered, non-empty inference-source list per step in + * `definition.stepOrder` so the workflow-process child can resolve inference + * at step invocation without a round trip to the hub. The list is the step's + * failover chain: element 0 is the active source (its id is the step's + * `defaultSource`), and the reactor fails over forward through the tail on a + * transient inference error. A workflow step pins a single-element list (no + * per-step failover); a single-agent instance pins the instance's full + * ordered source chain. Every `stepOrder` entry must have a matching + * `sources` entry; the validator rejects frames that violate this at the + * boundary. + */ +const WorkflowProjectionDefinition = type({ + id: "string > 0", + triggers: "unknown[]", + stepOrder: "string[]", + steps: { "[string]": "unknown" }, + "state?": "Record", + "+": "delete", +}); + +/** + * A workflow projection paired with its per-step inference-source pins, with + * the invariant that every `stepOrder` entry has a `sources` failover chain. + * The narrow here is the same coverage check the top-level `AgentDeployWorkflow` + * applies to its own definition; this reusable form carries it into each + * extracted onTrigger body under `referencedDefinitions`, so a body's sources + * cover the body's stepOrder just as the top-level's cover the top-level's. + */ +const WorkflowProjectionWithSources = type({ + definition: WorkflowProjectionDefinition, + sources: { "[string]": InferenceSource.array().atLeastLength(1) }, +}).narrow((value, ctx) => { + for (const stepId of value.definition.stepOrder) { + if (!Object.prototype.hasOwnProperty.call(value.sources, stepId)) { + return ctx.mustBe( + `a workflow projection whose sources cover every step in stepOrder; ${JSON.stringify(stepId)} is missing`, + ); + } + } + return true; +}); + +export const AgentDeployWorkflow = type({ + definition: WorkflowProjectionDefinition, + sources: { "[string]": InferenceSource.array().atLeastLength(1) }, + // Extracted onTrigger section bodies, materialized to their own workflow + // assets on the sidecar so a body child's spawn-child resolves the body by + // ref without a hub round-trip (the body id IS the asset ref). Optional: only + // an onTrigger deploy carries it, and every existing non-onTrigger deploy + // omits it and still validates. Each entry carries the body definition AND + // the body's own per-step inference-source pins, materialized beside the body + // on disk (`sources.json`) so a body child -- in-process, its env lost across + // a restart -- resolves inference durably without a hub round-trip. + "referencedDefinitions?": WorkflowProjectionWithSources.array(), +}).narrow((value, ctx) => { + for (const stepId of value.definition.stepOrder) { + if (!Object.prototype.hasOwnProperty.call(value.sources, stepId)) { + return ctx.mustBe( + `a workflow projection whose sources cover every step in stepOrder; ${JSON.stringify(stepId)} is missing`, + ); + } + } + return true; +}); +export type AgentDeployWorkflow = typeof AgentDeployWorkflow.infer; + +/** + * Deploy an agent to this sidecar. The sidecar spawns a supervised + * workflow-process child to host the deployment. + * + * The deploy router discriminates two shapes by field presence without + * consulting `config`: + * - `workflow` set: a workflow deployment (single-step head or multi-step) + * that spawns the supervised workflow-process child. + * - `provisionStep` true: a no-spawn per-step provision of a multi-step + * deploy -- the sidecar initializes the step's agent-state repo and + * records the hub key so the follow-up deploy pack applies and verifies, + * but spawns nothing. The deployment-level `workflow` frame (sent once + * after every step is provisioned) spawns the child. + * A frame carrying neither is rejected -- there is no in-process + * fall-through. `workflow` and `provisionStep` are mutually exclusive. + */ +export const AgentDeployFrame = type({ + type: "'agent.deploy'", + agentAddress: "string", + agentId: "string", + config: HarnessConfig, + hubPublicKey: "string", + "workflow?": AgentDeployWorkflow, + "provisionStep?": "boolean", +}); +export type AgentDeployFrame = typeof AgentDeployFrame.infer; + +/** + * Remove an agent from this sidecar. The sidecar tears down the harness, + * pushes state to the hub (best-effort), deletes the agent directory, and + * responds with agent.undeploy.ack. + */ +export const AgentUndeployFrame = type({ + type: "'agent.undeploy'", + agentAddress: "string", + reason: "string", +}); +export type AgentUndeployFrame = typeof AgentUndeployFrame.infer; + +/** + * Per-address cryptographic challenge. The sidecar must sign + * `nonce || utf8(address)` with each agent's private key and respond + * with a challenge.response frame. + */ +export const ChallengeFrame = type({ + type: "'challenge'", + challenges: type({ address: "string", nonce: "string" }).array(), +}); +export type ChallengeFrame = typeof ChallengeFrame.infer; + +/** + * Sent when challenge verification fails for a specific address. + */ +export const ChallengeFailedFrame = type({ + type: "'challenge.failed'", + address: "string", + reason: "string", +}); +export type ChallengeFailedFrame = typeof ChallengeFailedFrame.infer; + +/** + * Keepalive pong sent by the hub in response to a ping frame. + * If the sidecar stops receiving pongs, it considers the hub dead. + */ +export const PongFrame = type({ type: "'pong'" }); +export type PongFrame = typeof PongFrame.infer; + +/** + * Push an updated inference-source list to a running single-step + * deployment. The sidecar routes it to the deployment's supervisor, which + * delivers it to the warm agent and swaps its sources in place. `sources` + * is non-empty (validated at this boundary, mirroring the deploy frame's + * per-step source arrays). Element 0 is the active source; the producer + * sets `defaultSource` to its id -- that equality is producer-enforced, + * not checked here. Responds with session.ack or session.error. + */ +export const SourcesUpdateFrame = type({ + type: "'sources.update'", + requestId: "string", + agentAddress: "string", + sources: InferenceSource.array().atLeastLength(1), + defaultSource: "string", +}); +export type SourcesUpdateFrame = typeof SourcesUpdateFrame.infer; + +// --------------------------------------------------------------------------- +// Pack transport (bidirectional) +// --------------------------------------------------------------------------- +// +// Git pack data is streamed between hub and sidecar over the existing JSON +// WebSocket. Chunks are base64-encoded (matching the mail convention above). +// A transfer is a sequence of repo.pack.push frames followed by a +// repo.pack.done, correlated by transferId. The receiver responds with +// repo.pack.ack or repo.pack.reject. +// +// Each pack frame carries two complementary addressing fields: +// +// - `agentAddress` identifies the destination agent on the receiving +// sidecar. The sidecar manages per-agent state and uses this field to +// route the pack to the correct workspace. For agent-state packs the +// sidecar applies the pack onto the agent's deploy/state tree. +// +// - `repoId` identifies the source repo at the hub. The hub maps `repoId` +// to the originating entry in its kind-keyed RepoStore. For +// `repoId.kind === "agent-state"`, `repoId.id` is the agent address +// (the deploy/state repo and the destination agent are the same), so +// the two fields carry the same value. Future kinds (e.g. assets) use +// `repoId` to name a non-agent source while `agentAddress` continues +// to address the destination agent. +// +// Flow control: deferred. Agent deploy trees are small enough that the sender +// can push all chunks without windowing. If this becomes a problem, a credit- +// based mechanism can be added later. + +/** + * Tag identifying a kind of repository in the hub's kind-keyed RepoStore. + * Lives in `@intx/types` because the wire-level pack frames reference it; + * the substrate package re-exports it for handler authors. + */ +export const RepoKind = type.enumerated( + "agent-state", + "skill", + "package-registry", + "workflow", + "workflow-run", +); +export type RepoKind = typeof RepoKind.infer; + +/** + * Operations a principal may invoke against a repo in the RepoStore. + * Lives in `@intx/types` so storage layers (e.g. `@intx/db`) can validate + * persisted action vocabularies without depending on the substrate + * package. The substrate re-exports it for handler authors. + */ +export const RepoAction = type.enumerated( + "init", + "writeTree", + "receivePack", + "createPack", + "resolveRef", +); +export type RepoAction = typeof RepoAction.infer; + +/** + * Hub-side identity of a repository in the RepoStore. Pack frames carry + * this alongside `agentAddress` so the hub can map a pack back to the + * originating repo independently of which sidecar/agent it is destined for. + */ +export const RepoId = type({ + kind: RepoKind, + id: "string", +}); +export type RepoId = typeof RepoId.infer; + +/** + * A chunk of git pack data. The sender splits the packfile into chunks of at + * most 64 KiB (before base64 encoding) and sends them in order. + * + * `seq` is monotonically increasing per transferId, starting at 0. The + * receiver must reject the transfer if a gap is detected. + */ +export const PackPushFrame = type({ + type: "'repo.pack.push'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", + seq: "number", + data: "string", +}); +export type PackPushFrame = typeof PackPushFrame.infer; + +/** + * Signals the end of a pack transfer. The receiver applies the pack and + * updates `ref` to point at `commitSha`. If the post-apply HEAD does not + * match `commitSha`, the receiver must reject with reason "sha_mismatch". + * + * When `mountPath` is set, the receiver materializes the pack at + * `workspace//` instead of the hardcoded agent deploy tree. + * Absent for the agent-state deploy/state flows, which continue to apply + * the pack to the agent's repo as before. + */ +export const PackDoneFrame = type({ + type: "'repo.pack.done'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", + ref: "string", + commitSha: "string", + "mountPath?": "string", +}); +export type PackDoneFrame = typeof PackDoneFrame.infer; + +/** + * Receiver acknowledges successful application of a pack transfer. + */ +export const PackAckFrame = type({ + type: "'repo.pack.ack'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", +}); +export type PackAckFrame = typeof PackAckFrame.infer; + +export const PackRejectReason = type.enumerated( + "signature_invalid", + "path_violation", + "conflict", + "corrupt", + "sha_mismatch", + "timeout", +); +export type PackRejectReason = typeof PackRejectReason.infer; + +/** + * Receiver rejects a pack transfer. + */ +export const PackRejectFrame = type({ + type: "'repo.pack.reject'", + agentAddress: "string", + repoId: RepoId, + transferId: "string", + reason: PackRejectReason, +}); +export type PackRejectFrame = typeof PackRejectFrame.infer; + +/** + * Categories of deploy-apply failure surfaced by the sidecar's + * tool-package loader. Each value maps one-to-one to a distinct point in + * the apply pipeline; a single category fires per failed attempt. + * + * tarball.missing — a manifest entry's asset-sourced tarball + * is not present at the recorded path. + * asset.mount.missing — a `kind: "asset"` manifest entry names + * an `assetId` that the deploy pack's + * `deploy/asset-mounts.json` does not + * cover. Indicates a mismatch between the + * resolver's view of attached assets and + * the materialization fan-out, not a + * missing file on disk. + * integrity.mismatch — fetched tarball bytes do not match the + * manifest's pinned SRI integrity. + * registry.fetch.failed — the configured registry refused or + * dropped the request for a tarball. + * registry.unknown — the manifest entry references a registry + * name not present in the sidecar's + * registry config. + * registry.auth.failed — the registry rejected the sidecar's + * credentials. + * tarball.extract.failed — tar extraction failed or the extracted + * tree was malformed. + * manifest.invalid — the manifest itself did not validate + * at the loader boundary (JSON.parse + * failure or arktype schema failure). + * Peer-dependency violations are caught + * earlier by the hub's resolver and + * surface as a launch failure rather + * than this frame. + * package.entry.missing — a top-level package's package.json had + * no `interchange.tools` field. + * package.entry.invalid — the resolved `interchange.tools` module + * exported nothing that looked like an + * AnnotatedToolFactory. + * factory.construct.failed — a factory invocation threw, or required + * a capability key the env did not provide. + * tool.name.duplicate — a tool name is registered more than + * once in the apply's loaded set. The + * cross-bundle case (two pinned packages + * share a bundle id, producing colliding + * prefixed tool names) is rejected at + * apply time, before the caller commits. + * The intra-bundle case (one package + * exports two definitions sharing a raw + * name) surfaces at first agent + * construction with the same category + * instead of apply rejection: the loader + * cannot see `bundle.definitions` without + * invoking the factory, and the `BaseEnv` + * the factory needs is constructed by the + * sidecar harness AFTER the commit. Both + * paths carry the same category so the + * operator-facing failure shape is + * uniform regardless of which check + * fired; only the channel (apply.error + * frame vs runtime construct failure) + * differs. + * apply.swap.failed — DEPRECATED, no longer emitted. The apply + * protocol stages each deploy into a stable + * per-deploy-id directory and commits via a + * single `active-deploy-id` file write, so + * there is no filesystem rename that can + * fail. The value is retained in the enum + * for wire compatibility: during a rolling + * upgrade an older sidecar can still emit + * it, and dropping the member would make a + * newer hub's frame validator reject that + * frame. + * apply.previous-rotation.failed + * — every loaded factory validated and the + * new deploy was staged, but persisting the + * instance's `active-deploy-id` file (the + * commit) degraded: the id was written + * through the no-fsync / dirty-marker + * fallback ladder rather than durably + * flushed. The new deploy is logically + * live, so `previousDeployId` on this + * failure carries the NEW deploy id rather + * than the pre-apply one. The next boot + * reconciles the recorded id from the dirty + * marker. + */ +export const DeployApplyErrorCategory = type.enumerated( + "tarball.missing", + "asset.mount.missing", + "integrity.mismatch", + "registry.fetch.failed", + "registry.unknown", + "registry.auth.failed", + "tarball.extract.failed", + "manifest.invalid", + "package.entry.missing", + "package.entry.invalid", + "factory.construct.failed", + "tool.name.duplicate", + "apply.swap.failed", + "apply.previous-rotation.failed", +); +export type DeployApplyErrorCategory = typeof DeployApplyErrorCategory.infer; + +/** + * Hub requests the sidecar to push its current agent state. The sidecar + * responds by sending pack.push frames followed by pack.done using the + * same transferId. + */ +export const SyncRequestFrame = type({ + type: "'sync.request'", + agentAddress: "string", + transferId: "string", +}); +export type SyncRequestFrame = typeof SyncRequestFrame.infer; + +// --------------------------------------------------------------------------- +// Discriminated frame unions +// --------------------------------------------------------------------------- + +/** All frame types the sidecar sends to the hub. */ +export const SidecarFrame = RegisterFrame.or(ReconnectFrame) + .or(ChallengeResponseFrame) + .or(AgentDeployAckFrame) + .or(AgentErrorFrame) + .or(MailOutboundFrame) + .or(AgentEventFrame) + .or(ConnectorStateChangedFrame) + .or(PingFrame) + .or(SessionAckFrame) + .or(SessionErrorFrame) + .or(AgentUndeployAckFrame) + .or(SignalCorrelationRegisterFrame) + .or(PackPushFrame) + .or(PackDoneFrame) + .or(PackAckFrame) + .or(PackRejectFrame) + .or(MailInboundAckFrame); +export type SidecarFrame = typeof SidecarFrame.infer; + +/** All frame types the hub sends to the sidecar. */ +export const HubFrame = MailInboundFrame.or(AgentDeployFrame) + .or(AgentUndeployFrame) + .or(ChallengeFrame) + .or(ChallengeFailedFrame) + .or(PongFrame) + .or(SourcesUpdateFrame) + .or(PackPushFrame) + .or(PackDoneFrame) + .or(PackAckFrame) + .or(PackRejectFrame) + .or(SyncRequestFrame) + .or(SignalDeliverFrame) + .or(RunGrantsFrame) + .or(SignalCorrelationRegisterAckFrame) + .or(DrainDeliverFrame); +export type HubFrame = typeof HubFrame.infer; + +/** Any frame on the wire, regardless of direction. */ +export const WireFrame = SidecarFrame.or(HubFrame); +export type WireFrame = typeof WireFrame.infer; diff --git a/vendor/intx-types/src/signals.test.ts b/vendor/intx-types/src/signals.test.ts new file mode 100644 index 000000000..d044282fc --- /dev/null +++ b/vendor/intx-types/src/signals.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; + +import { GateType } from "./runtime"; +import { + ControlSignal, + correlationIdFromSignalName, + signalKinds, + signalKindToGateType, + signalName, +} from "./signals"; + +describe("signalKindToGateType exhaustiveness", () => { + for (const kind of signalKinds) { + test(`maps signal kind ${kind} to a valid GateType`, () => { + const gate = signalKindToGateType(kind); + expect(GateType(gate) instanceof type.errors).toBe(false); + }); + } +}); + +describe("ControlSignal", () => { + test("accepts a well-formed approval envelope", () => { + const signal = ControlSignal({ + correlationId: "corr-1", + kind: "approval", + outcome: "approved", + payload: { scope: "once" }, + }); + expect(signal instanceof type.errors).toBe(false); + }); + + test("rejects an outcome outside the approval vocabulary", () => { + const signal = ControlSignal({ + correlationId: "corr-1", + kind: "approval", + outcome: "settled", + payload: null, + }); + expect(signal instanceof type.errors).toBe(true); + }); + + test("rejects an unknown kind discriminant", () => { + const signal = ControlSignal({ + correlationId: "corr-1", + kind: "payment", + outcome: "approved", + payload: null, + }); + expect(signal instanceof type.errors).toBe(true); + }); +}); + +describe("signalName", () => { + test("mints the reserved __signal__ namespace for a correlation id", () => { + expect(signalName("corr-1")).toBe("__signal__:corr-1"); + }); +}); + +describe("correlationIdFromSignalName", () => { + // Round-trips the writer, including ids that themselves contain the + // reserved prefix or a colon: the reader slices a fixed offset rather + // than greedily stripping, so a nested prefix survives. + for (const id of ["corr-1", "", "a:b:c", "__signal__:nested", " spaces "]) { + test(`round-trips ${JSON.stringify(id)}`, () => { + expect(correlationIdFromSignalName(signalName(id))).toBe(id); + }); + } + + test("returns undefined for a free-form (non-reserved) signal name", () => { + expect(correlationIdFromSignalName("approval")).toBeUndefined(); + expect(correlationIdFromSignalName("")).toBeUndefined(); + // A single-underscore near-miss is not the reserved prefix. + expect(correlationIdFromSignalName("__signal:approval")).toBeUndefined(); + }); + + test("yields an empty correlation id for the prefix alone", () => { + expect(correlationIdFromSignalName("__signal__:")).toBe(""); + }); +}); diff --git a/vendor/intx-types/src/signals.ts b/vendor/intx-types/src/signals.ts new file mode 100644 index 000000000..53855c26d --- /dev/null +++ b/vendor/intx-types/src/signals.ts @@ -0,0 +1,96 @@ +import { type } from "arktype"; + +import type { GateType } from "./runtime"; + +/** + * The kinds of external control signal an agent can suspend on and later + * resume from. Exposed as both an arktype validator (so members are + * iterable and can be composed into wire validators) and a derived + * TypeScript union. + */ +export const signalKinds = ["approval"] as const; +export const SignalKind = type.enumerated(...signalKinds); +export type SignalKind = typeof SignalKind.infer; + +/** + * The internal resumption taxonomy: how a parked run resumes, keyed by + * (`kind`, `outcome`). This is NOT the approver's wire decision -- that is + * `ApprovalDecision`, which the delivery path parses. `ControlSignal` is the + * `kind`-discriminated union the resumption dispatch is designed around; + * `correlationId` ties an entry back to the suspension it resolves and + * `payload` carries kind-specific data opaquely. It is intentionally ahead of + * its consumers: the `approval` arm is the only one wired today, and its + * `timeout` outcome arrives via the gate-timeout path, not as a delivered + * decision. Each remaining signal flow activates its own arm as it lands. + */ +export const ControlSignal = type({ + correlationId: "string", + kind: "'approval'", + outcome: "'approved' | 'rejected' | 'timeout'", + payload: "unknown", +}); +export type ControlSignal = typeof ControlSignal.infer; + +/** + * The decision an approver hands back when they resolve an approval. This is + * the payload delivered to the parked run through `sendSignalDeliver`; the + * run's `parkOnSignal` awaitNext returns it verbatim as the correlated inbound. + * `scope` is deliberately absent: it is a storage-and-grant concern the + * resolver records on the approval row, not something the resumed run consumes. + */ +export const ApprovalDecision = type({ + outcome: "'approved' | 'rejected'", + "message?": "string", +}); +export type ApprovalDecision = typeof ApprovalDecision.infer; + +/** + * Map a signal kind to the reactor gate type it clears. The default arm + * calls `assertNever` so a newly added SignalKind that is not classified + * here fails to type-check — a bare switch without a default does not. + */ +export function signalKindToGateType(kind: SignalKind): GateType { + switch (kind) { + case "approval": + return "approval"; + default: + return assertNever(kind); + } +} + +function assertNever(x: never): never { + throw new Error(`Unclassified signal kind: ${JSON.stringify(x)}`); +} + +/** + * The reserved prefix that marks a signal name as an internal + * control-plane channel rather than a free-form `awaitSignal` gate name. + * The writer (`signalName`) and the reader (`correlationIdFromSignalName`) + * share this one constant so the two cannot drift. + */ +const SIGNAL_NAME_PREFIX = "__signal__:"; + +/** + * Construct the reserved, `__signal__:`-prefixed name under which a control + * signal for `correlationId` is delivered. This reserves a name namespace + * distinct from the user-authored workflow-signal names that flow through + * `SignalDeliverFrame.signalName` in `./sidecar`: those are free-form + * `awaitSignal` gate names chosen by workflow authors, whereas this helper + * mints an internal name the control plane owns, so the two cannot collide. + */ +export function signalName(correlationId: string): string { + return `${SIGNAL_NAME_PREFIX}${correlationId}`; +} + +/** + * Recover the `correlationId` from a reserved control-plane signal name + * minted by `signalName`. Returns `undefined` for a name that does not + * carry the reserved prefix (a free-form `awaitSignal` gate name), so a + * caller can tell a control-plane channel apart from an author-chosen one. + * Symmetric with `signalName`: `correlationIdFromSignalName(signalName(id)) + * === id`. + */ +export function correlationIdFromSignalName(name: string): string | undefined { + if (!name.startsWith(SIGNAL_NAME_PREFIX)) return undefined; + return name.slice(SIGNAL_NAME_PREFIX.length); +} diff --git a/vendor/intx-types/src/tenants.ts b/vendor/intx-types/src/tenants.ts new file mode 100644 index 000000000..6ce6ddf6a --- /dev/null +++ b/vendor/intx-types/src/tenants.ts @@ -0,0 +1,36 @@ +import { type } from "arktype"; + +export const CreateTenant = type({ + name: "string", + slug: "string", + "parentId?": "string | null", +}); + +export const UpdateTenant = type({ + "name?": "string", + "config?": "Record", +}); + +export const TenantResponse = type({ + id: "string", + name: "string", + slug: "string", + domain: "string", + "parentId?": "string | null", + "config?": "Record", + createdAt: "string", + updatedAt: "string", +}); + +export const FederationTrust = type({ + tenantId: "string", + tenantName: "string", + tenantDomain: "string", + direction: "'inbound' | 'outbound' | 'bilateral'", + createdAt: "string", +}); + +export const CreateFederationTrust = type({ + targetTenantId: "string", + direction: "'inbound' | 'outbound' | 'bilateral'", +}); diff --git a/vendor/intx-types/src/tool-packages.test.ts b/vendor/intx-types/src/tool-packages.test.ts new file mode 100644 index 000000000..5e539e7d4 --- /dev/null +++ b/vendor/intx-types/src/tool-packages.test.ts @@ -0,0 +1,298 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; +import { + ToolPackagePin, + ToolPackagePinArray, + ToolPackageSource, + ToolPackageManifestEntry, + ToolPackageManifest, +} from "./tool-packages"; + +describe("ToolPackagePin", () => { + test("accepts a name and version", () => { + const result = ToolPackagePin({ + name: "@intx/tools-posix", + version: "1.2.3", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects missing name", () => { + const result = ToolPackagePin({ version: "1.2.3" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects missing version", () => { + const result = ToolPackagePin({ name: "@intx/tools-posix" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects non-string name", () => { + const result = ToolPackagePin({ name: 42, version: "1.2.3" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an uppercase name", () => { + const result = ToolPackagePin({ name: "Tools-Posix", version: "1.2.3" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an uppercase scope", () => { + const result = ToolPackagePin({ name: "@INTX/tools", version: "1.2.3" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a leading-dot name", () => { + const result = ToolPackagePin({ name: ".hidden", version: "1.2.3" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts a star range at the pin level", () => { + const result = ToolPackagePin({ name: "left-pad", version: "*" }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a caret range at the pin level", () => { + const result = ToolPackagePin({ name: "left-pad", version: "^1.2.3" }); + expect(result instanceof type.errors).toBe(false); + }); +}); + +describe("ToolPackagePinArray", () => { + test("accepts an array with distinct names", () => { + const result = ToolPackagePinArray([ + { name: "@intx/tools-posix", version: "1.2.3" }, + { name: "@intx/tools-mail", version: "1.2.3" }, + ]); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an empty array", () => { + const result = ToolPackagePinArray([]); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an array with duplicate names", () => { + const result = ToolPackagePinArray([ + { name: "@intx/tools-posix", version: "1.2.3" }, + { name: "@intx/tools-posix", version: "1.3.0" }, + ]); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an array containing an invalid pin name", () => { + const result = ToolPackagePinArray([{ name: "BadName", version: "1.2.3" }]); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an array containing an unparseable semver range", () => { + const result = ToolPackagePinArray([ + { name: "left-pad", version: "not-a-range" }, + ]); + expect(result instanceof type.errors).toBe(true); + }); + + test("accepts a star range", () => { + const result = ToolPackagePinArray([{ name: "left-pad", version: "*" }]); + expect(result instanceof type.errors).toBe(false); + }); +}); + +describe("ToolPackageSource", () => { + test("accepts an asset source", () => { + const result = ToolPackageSource({ + kind: "asset", + assetId: "asset_abc", + path: "tarballs/foo-1.2.3.tgz", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a registry source", () => { + const result = ToolPackageSource({ + kind: "registry", + registry: "npmjs", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an unknown kind", () => { + const result = ToolPackageSource({ + kind: "ftp", + path: "foo", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an asset source missing path", () => { + const result = ToolPackageSource({ kind: "asset", assetId: "asset_abc" }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an asset source missing assetId", () => { + const result = ToolPackageSource({ + kind: "asset", + path: "tarballs/foo.tgz", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects a registry source missing registry", () => { + const result = ToolPackageSource({ kind: "registry" }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("ToolPackageManifestEntry", () => { + const validEntry = { + name: "@intx/tools-posix", + version: "1.2.3", + integrity: "sha512-AAAA", + source: { + kind: "asset", + assetId: "asset_workspace_builtins", + path: "tarballs/intx-tools-posix-1.2.3.tgz", + }, + } as const; + + test("accepts a minimal entry", () => { + const result = ToolPackageManifestEntry(validEntry); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts os and cpu metadata", () => { + const result = ToolPackageManifestEntry({ + ...validEntry, + os: ["darwin", "linux"], + cpu: ["arm64", "x64"], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a tarballUrl on a registry entry", () => { + const result = ToolPackageManifestEntry({ + name: "left-pad", + version: "1.3.0", + integrity: "sha512-BBBB", + source: { kind: "registry", registry: "npmjs" }, + tarballUrl: "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects missing integrity", () => { + const result = ToolPackageManifestEntry({ + name: validEntry.name, + version: validEntry.version, + source: validEntry.source, + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an unknown source kind", () => { + const result = ToolPackageManifestEntry({ + ...validEntry, + source: { kind: "ftp", url: "ftp://example.com/foo" }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + +describe("ToolPackageManifest", () => { + const validManifest = { + schemaVersion: "1", + topLevel: [{ name: "@intx/tools-posix", version: "1.2.3" }], + entries: [ + { + name: "@intx/tools-posix", + version: "1.2.3", + integrity: "sha512-AAAA", + source: { + kind: "asset", + assetId: "asset_workspace_builtins", + path: "tarballs/intx-tools-posix-1.2.3.tgz", + }, + }, + ], + } as const; + + test("accepts a minimal valid manifest", () => { + const result = ToolPackageManifest(validManifest); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an empty topLevel and entries", () => { + const result = ToolPackageManifest({ + schemaVersion: "1", + topLevel: [], + entries: [], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts a closure with transitive entries", () => { + const result = ToolPackageManifest({ + schemaVersion: "1", + topLevel: [{ name: "tools-with-deps", version: "1.0.0" }], + entries: [ + { + name: "tools-with-deps", + version: "1.0.0", + integrity: "sha512-CCCC", + source: { + kind: "asset", + assetId: "asset_workspace_builtins", + path: "tarballs/tools-with-deps-1.0.0.tgz", + }, + }, + { + name: "left-pad", + version: "1.3.0", + integrity: "sha512-DDDD", + source: { kind: "registry", registry: "npmjs" }, + }, + ], + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects an unknown schemaVersion", () => { + const result = ToolPackageManifest({ + ...validManifest, + schemaVersion: "2", + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects missing topLevel", () => { + const result = ToolPackageManifest({ + schemaVersion: "1", + entries: [], + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects missing entries", () => { + const result = ToolPackageManifest({ + schemaVersion: "1", + topLevel: [], + }); + expect(result instanceof type.errors).toBe(true); + }); + + test("rejects an entry with a malformed source", () => { + const result = ToolPackageManifest({ + schemaVersion: "1", + topLevel: [{ name: "foo", version: "1.0.0" }], + entries: [ + { + name: "foo", + version: "1.0.0", + integrity: "sha512-EEEE", + source: { kind: "asset" }, + }, + ], + }); + expect(result instanceof type.errors).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/tool-packages.ts b/vendor/intx-types/src/tool-packages.ts new file mode 100644 index 000000000..db55dd762 --- /dev/null +++ b/vendor/intx-types/src/tool-packages.ts @@ -0,0 +1,205 @@ +// Schemas for the tool-package distribution path. +// +// An agent pins one or more tool packages via `ToolPackagePin[]`. At +// deploy-assembly time, the hub walks the pinned set, resolves the full +// dependency closure, and writes a `ToolPackageManifest` into the deploy +// pack. The sidecar reads the manifest at apply time and materializes +// every entry. +// +// Only entries listed in `topLevel` contribute tools to the agent; +// transitive entries exist to satisfy `require()` / `import` resolution +// inside the top-level packages. + +import { type } from "arktype"; +import semver from "semver"; + +/** + * npm's documented package-name rules expressed as an arktype regex + * literal: lowercase, may begin with a scope (`@scope/`), the rest of + * each segment is URL-safe (letters, digits, `_`, `-`, `.`), no + * leading dot or underscore, scoped names require a `/`. The npm + * registry rejects anything else; mirroring the rule at the REST + * boundary keeps mixed-case or malformed pins from threading past + * the API into the resolver, which would otherwise self-resolve + * them and then fail at the sidecar loader. + * + * Using a regex literal (rather than a `narrow` predicate) lets the + * JSON-Schema generator surface the rule as a `pattern` field in the + * OpenAPI spec without a fallback hook. + */ +export const ToolPackagePinName = type( + /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/, +); + +/** + * A pin in an agent definition: name + version range. The hub resolves + * this against configured registries at deploy-assembly time. + * + * `version` is an npm-style spec ("^1.2.3", "~1.2", "1.2.3", "*"). + * Resolution is performed by `npm-pick-manifest` against the registry + * packument. Semver-range validation lives on `ToolPackagePinArray` + * (below) so the JSON-Schema generator sees a plain string here; the + * array narrow is the actual REST boundary for pins and runs before + * any value reaches the resolver. + * + * `name` must match npm's documented package-name rules — lowercase, + * optional scope prefix, URL-safe characters only. npm itself rejects + * uppercase names; packuments arrive lowercased, so a mixed-case pin + * would self-resolve and then silently fail the sidecar loader's + * `${name}@${version}` lookup against the lowercase entry the + * packument produced. + * + * A `ToolPackagePin[]` must contain at most one entry per `name`. Use + * `ToolPackagePinArray` (below) at REST boundaries to enforce dedup + * before the resolver runs; the resolver still rejects duplicates at + * its own boundary as belt-and-suspenders. + */ +export const ToolPackagePin = type({ + name: ToolPackagePinName, + version: "string", +}); +export type ToolPackagePin = typeof ToolPackagePin.infer; + +/** + * Array of pins with the no-duplicate-name and parseable-version + * invariants enforced at parse time. The downstream resolver keys + * its top-level resolution map by name; two pins of the same name + * would silently collapse to the first arrival's resolved version, + * and an unparseable semver range would fail mid-walk. Rejecting + * both at the REST boundary surfaces the bug to the caller instead + * of leaving it to misbehave at launch time. + * + * `*` is accepted as the documented any-version range; anything + * else must satisfy `semver.validRange`. + * + * NOTE: the same `*` special-case lives in `parsePin` inside the + * tool-packaging resolver. Any new magic-range additions need to be + * carved at both sites — the packages are separated by the wire-type + * vs. resolver boundary and cannot import each other. + */ +export const ToolPackagePinArray = ToolPackagePin.array().narrow( + (pins, ctx) => { + const seen = new Set(); + for (const pin of pins) { + if (seen.has(pin.name)) { + return ctx.mustBe( + `an array with no duplicate package names; "${pin.name}" appears more than once`, + ); + } + seen.add(pin.name); + if (pin.version !== "*" && semver.validRange(pin.version) === null) { + return ctx.mustBe( + `every pin to carry a parseable semver range; "${pin.name}" has version ${JSON.stringify(pin.version)}`, + ); + } + } + return true; + }, +); +export type ToolPackagePinArray = typeof ToolPackagePinArray.infer; + +/** + * A pinned entry's tarball lives inside an asset attached to the + * agent at session time. `assetId` is the hub-side asset row id; the + * sidecar resolves it against the deploy pack's `deploy/asset-mounts.json` + * map to get a workspace-relative mount path, then opens the tarball at + * `//`. `path` is the asset-root-relative + * POSIX path of the tarball blob (the package-registry kind handler + * stores them under `tarballs/.tgz`). + * + * Pre-INTR-108 manifests carried `path` without `assetId` because the + * single attached asset was implicit; the substrate now supports + * multiple attached package-registry assets, so the entry must name + * which asset to read from. + */ +export const ToolPackageAssetSource = type({ + kind: "'asset'", + assetId: "string", + path: "string", +}); +export type ToolPackageAssetSource = typeof ToolPackageAssetSource.infer; + +/** + * A pinned entry's tarball is fetched from the named registry at apply + * time. The sidecar's registry config maps `registry` to a URL and + * credentials. + */ +export const ToolPackageRegistrySource = type({ + kind: "'registry'", + registry: "string", +}); +export type ToolPackageRegistrySource = typeof ToolPackageRegistrySource.infer; + +/** + * Discriminated union over where a manifest entry's bytes come from. + */ +export const ToolPackageSource = ToolPackageAssetSource.or( + ToolPackageRegistrySource, +); +export type ToolPackageSource = typeof ToolPackageSource.infer; + +/** + * A single pinned package in the closure. + * + * `integrity` is an SRI string ("sha512-..."). The loader verifies + * fetched bytes against it before unpacking. + * + * `os` / `cpu` are present when the entry comes from an + * `optionalDependencies` declaration with platform constraints. The + * sidecar filters entries by its own host before fetching; entries + * whose `os` or `cpu` does not include the host's value are skipped + * with a `platform.mismatch.skipped` debug log. + * + * `tarballUrl` is preserved for registry-sourced entries so the sidecar + * can fetch without re-resolving against the registry's packument; the + * hub recorded the exact URL the registry served at resolution time. + */ +export const ToolPackageManifestEntry = type({ + name: "string", + version: "string", + integrity: "string", + source: ToolPackageSource, + "os?": "string[]", + "cpu?": "string[]", + "tarballUrl?": "string", +}); +export type ToolPackageManifestEntry = typeof ToolPackageManifestEntry.infer; + +/** + * The manifest written into the deploy pack at + * `deploy/tool-packages-manifest.json`. + * + * `schemaVersion` is a literal "1" for now. Future schema changes bump + * this and the loader refuses unknown versions with `manifest.invalid`. + * + * `topLevel` enumerates the packages the agent definition explicitly + * pinned. The loader only scans these for `interchange.tools`; entries + * present in `entries` but absent from `topLevel` are transitive + * dependencies materialized for runtime `require()` / `import` + * resolution. + * + * Although `topLevel` shares the `ToolPackagePin` shape used at agent + * definition time, the `version` field here is always a concrete + * version (e.g. `"1.2.3"`), not a range. The resolver walks each + * agent-side pin's range through `npm-pick-manifest` and writes the + * picked version. The sidecar loader pairs `topLevel[i]` against + * `entries[j]` by `${name}@${version}` equality, so a range-form + * `version` here would never match any entry and the package would + * silently contribute no tool factories at apply time. + * + * `entries` carries the full pinned closure: every top-level pin plus + * every transitive dependency, deduped by `(name, version)`. The + * sidecar materializes every entry whose `os`/`cpu` matches its host. + */ +export const ToolPackageManifest = type({ + schemaVersion: "'1'", + // Use the array-level narrow so the wire validator catches duplicate + // top-level names directly, even when the manifest is produced by a + // hub the resolver did not author. The resolver enforces uniqueness + // when building the manifest; the validator is the second line of + // defense for any third-party hub or hand-edited file that slips a + // duplicate through. + topLevel: ToolPackagePinArray, + entries: ToolPackageManifestEntry.array(), +}); +export type ToolPackageManifest = typeof ToolPackageManifest.infer; diff --git a/vendor/intx-types/src/wallets.ts b/vendor/intx-types/src/wallets.ts new file mode 100644 index 000000000..0c66ac428 --- /dev/null +++ b/vendor/intx-types/src/wallets.ts @@ -0,0 +1,59 @@ +import { type } from "arktype"; + +export const walletBackendTypes = ["crypto", "fiat", "credits"] as const; +export type WalletBackendType = (typeof walletBackendTypes)[number]; + +const BackendType = type.enumerated(...walletBackendTypes); + +const backendTypeDescription = + "Settlement backend the wallet is denominated in: `crypto` (on-chain assets), `fiat` (national currency), or `credits` (internal accounting units). Determines how balances and transactions are settled."; + +const walletConfigDescription = + "Backend-specific configuration for the wallet (for example chain or account details for a `crypto` backend). Shape depends on `backendType`; not interpreted by the hub."; + +const balanceDescription = + "Current balance as a decimal string in the wallet's `currency`. Stored as a string to preserve precision for both crypto and fiat amounts."; + +export const CreateWallet = type({ + name: "string", + backendType: BackendType.describe(backendTypeDescription), + currency: "string", + "config?": type("Record").describe(walletConfigDescription), +}); + +export const UpdateWallet = type({ + "name?": "string", + "config?": type("Record").describe(walletConfigDescription), +}); + +export const WalletResponse = type({ + id: "string", + tenantId: "string", + name: "string", + backendType: BackendType.describe(backendTypeDescription), + currency: "string", + balance: type("string").describe(balanceDescription), + "config?": type("Record").describe(walletConfigDescription), + createdAt: "string", + updatedAt: "string", +}); + +export const TransactionResponse = type({ + id: "string", + walletId: "string", + "runId?": "string | null", + direction: type("'inbound' | 'outbound'").describe( + "Whether funds moved into the wallet (`inbound`) or out of it (`outbound`).", + ), + amount: type("string").describe( + "Transaction amount as a decimal string in `currency`, stored as a string to preserve precision.", + ), + currency: "string", + "recipientId?": "string | null", + "senderId?": "string | null", + "requestId?": "string | null", + status: type("'pending' | 'completed' | 'failed'").describe( + "Settlement state of the transaction: `pending` (initiated, not yet settled), `completed`, or `failed`.", + ), + createdAt: "string", +}); diff --git a/vendor/intx-types/src/workflow-run-id.ts b/vendor/intx-types/src/workflow-run-id.ts new file mode 100644 index 000000000..2b3728c92 --- /dev/null +++ b/vendor/intx-types/src/workflow-run-id.ts @@ -0,0 +1,27 @@ +// Canonical runId derivation for a workflow deployment's runs. +// +// Every run of a workflow deployment shares ONE stable runId: the +// deployment's mail address (`ins_@`). The +// supervisor's dispatch loop keys its per-run state, its grants barrier, +// and its terminal wait on this id. Every producer of a run's grants -- +// the hub-api trigger route and the sidecar's mail-deliver path -- must +// stage those grants under the SAME id, or they land under a run id the +// supervisor never looks up and the run fails closed on its `onRunStart` +// barrier. +// +// This module is the single source of truth those producers import, so +// their derivations cannot diverge. It exists to end the divergence that +// let the mail's Message-ID (a per-message identifier) masquerade as the +// runId: the runId is a property of the deployment, not of the individual +// message that triggers a run. + +/** + * The stable runId for every run of a workflow deployment: its mail + * address. Callers hold the deployment mail address in different forms -- + * a routing recipient, a supervisor binding, a route-derived address -- + * and route it through this one function so the runId contract is stated + * in exactly one place. + */ +export function deriveWorkflowRunId(deploymentMailAddress: string): string { + return deploymentMailAddress; +} diff --git a/vendor/intx-types/src/workflows.ts b/vendor/intx-types/src/workflows.ts new file mode 100644 index 000000000..c27e20e1e --- /dev/null +++ b/vendor/intx-types/src/workflows.ts @@ -0,0 +1,50 @@ +// Status vocabulary for the first-class workflow definition model, kept in its +// own workflow-scoped module so no workflow table or validator depends on a +// type from the agent surface. +export const workflowDefinitionStatuses = ["deployed", "stopped"] as const; +export type WorkflowDefinitionStatus = + (typeof workflowDefinitionStatuses)[number]; + +export const workflowDefinitionVersionStatuses = [ + "active", + "inactive", + "failed", +] as const; +export type WorkflowDefinitionVersionStatus = + (typeof workflowDefinitionVersionStatuses)[number]; + +import { type } from "arktype"; + +const WorkflowDefinitionStatusType = type.enumerated( + ...workflowDefinitionStatuses, +); +const WorkflowDefinitionVersionStatusType = type.enumerated( + ...workflowDefinitionVersionStatuses, +); + +// One entry in a definition's version history. +export const WorkflowDefinitionVersion = type({ + version: "string", + status: WorkflowDefinitionVersionStatusType, + createdAt: "string", +}); + +// The first-class workflow definition, as returned by the definition routes. +export const WorkflowDefinitionResponse = type({ + id: "string", + tenantId: "string", + name: "string", + "description?": "string | null", + currentVersion: "string", + status: WorkflowDefinitionStatusType.describe( + "Lifecycle state of the definition: `deployed` (a launchable version is active) or `stopped` (deactivated).", + ), + createdAt: "string", + updatedAt: "string", +}); + +// Rollback a definition to a prior version. Kept separate from the agent +// RollbackRequest so the workflow surface carries no agent-type dependency. +export const WorkflowRollbackRequest = type({ + version: "string", +});