From ea6b7a8575965b6c47a64bfca64800301347e6c0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 03:14:01 -0700 Subject: [PATCH] Vendor the @intx leaf packages the re-pin compiles against at a8bc06ae Re-pinning any vendored @intx tree to upstream origin/main (a8bc06ae, 2026-08-27) needs the newer type surface of the packages it imports, and npm is still 0.3.0. Vendor those leaves at the same commit so no vendored tree ever mixes pins: types, agent, inference, mime, mail-memory, and the new mailbox package (not published at all). Root overrides now point each vendored name at workspace:* so the published @intx/harness, hub-agent, tool-packaging, authz, ... resolve their own @intx/* dependencies onto the vendored copies instead of a second npm copy; unchanged names stay pinned to 0.3.0. @types/ssri joins the root catalog the way upstream carries it. Two ledgered edits: inference builds the Gemini upload body as new Uint8Array(bytes) because TS 6's lib.dom BodyInit rejects Uint8Array in the DOM-lib packages that compile this source; the still-pinned workflow-host's supervisor-backed transport returns the expunged uids from its expunge stub to match the re-pinned types (bridging edit, gone with that tree's re-pin). --- VENDORED.md | 38 +- apps/hub/package.json | 4 +- apps/sidecar/package.json | 8 +- apps/web/package.json | 2 +- bun.lock | 328 +- package.json | 16 +- packages/agent-directory-tools/package.json | 4 +- packages/agent-directory/package.json | 4 +- packages/agent-runtime/package.json | 4 +- .../agent-workflow-authoring/package.json | 2 +- packages/approvals/package.json | 2 +- packages/artifacts-hub/package.json | 2 +- packages/bench-ui/package.json | 2 +- packages/capability-tools/package.json | 4 +- packages/catalog-tools/package.json | 4 +- packages/chat-ui/package.json | 2 +- .../test/inference-preamble-drift.test.ts | 15 +- packages/chat/package.json | 6 +- packages/cli/package.json | 2 +- packages/connections-tools/package.json | 4 +- packages/connections/package.json | 2 +- packages/credential-providers/package.json | 2 +- packages/evals/package.json | 4 +- packages/folded-run-one-shot/package.json | 2 +- packages/folded-runs/package.json | 4 +- packages/github-tools/package.json | 4 +- packages/granola-tools/package.json | 4 +- packages/hub-client/package.json | 4 +- packages/inference-catalog/package.json | 2 +- packages/inference-settings/package.json | 2 +- packages/insights/package.json | 2 +- packages/interaction-tools/package.json | 4 +- packages/jimmy-agent/package.json | 4 +- packages/linear-tools/package.json | 4 +- packages/longevity-sim/package.json | 2 +- packages/mcp-tools/package.json | 4 +- packages/memory-tools/package.json | 4 +- packages/notify/package.json | 2 +- packages/ollama-adapter/package.json | 4 +- packages/onboarding/package.json | 2 +- packages/reddit-tools/package.json | 4 +- packages/routines-tools/package.json | 4 +- packages/run-scope/package.json | 2 +- packages/scout-agent/package.json | 4 +- packages/settings-ui/package.json | 2 +- packages/sidecar-placement/package.json | 2 +- packages/skills-tools/package.json | 4 +- packages/tool-registry-publish/package.json | 4 +- packages/tools-skills/package.json | 4 +- packages/web-search-tools/package.json | 4 +- packages/webhook-triggers/package.json | 2 +- packages/workflow-deploy-source/package.json | 2 +- packages/workflow-freeze/package.json | 4 +- packages/workflow-host-actions/package.json | 2 +- scripts/checks/kill-dates.txt | 8 +- vendor/intx/agent/CONVENTIONS.md | 172 + vendor/intx/agent/README.md | 122 + vendor/intx/agent/VENDORED-FROM | 4 + vendor/intx/agent/package.json | 40 + vendor/intx/agent/src/agent.ts | 899 +++++ vendor/intx/agent/src/canonicalize.ts | 208 ++ vendor/intx/agent/src/default-director.ts | 70 + vendor/intx/agent/src/definition.ts | 200 ++ vendor/intx/agent/src/director-registry.ts | 116 + vendor/intx/agent/src/director-types.ts | 111 + vendor/intx/agent/src/director.ts | 175 + vendor/intx/agent/src/env-validation.ts | 214 ++ vendor/intx/agent/src/env.ts | 235 ++ vendor/intx/agent/src/index.ts | 99 + .../intx/agent/src/internal-fixtures/mail.ts | 113 + .../agent/src/internal-fixtures/planner.ts | 59 + vendor/intx/agent/src/lock.ts | 59 + vendor/intx/agent/src/namespace.ts | 45 + vendor/intx/agent/src/send-queue.ts | 200 ++ vendor/intx/agent/src/source.ts | 179 + vendor/intx/agent/src/stream.ts | 142 + vendor/intx/agent/src/testing/audit-noop.ts | 29 + .../intx/agent/src/testing/authorize-allow.ts | 22 + vendor/intx/agent/src/testing/index.ts | 18 + vendor/intx/agent/src/tool.ts | 433 +++ vendor/intx/agent/tsconfig.json | 11 + vendor/intx/inference/README.md | 46 + vendor/intx/inference/VENDORED-FROM | 4 + vendor/intx/inference/package.json | 38 + vendor/intx/inference/src/actions.ts | 245 ++ vendor/intx/inference/src/adapter.ts | 139 + vendor/intx/inference/src/assembly.ts | 271 ++ vendor/intx/inference/src/audit-collector.ts | 172 + vendor/intx/inference/src/auth.ts | 61 + vendor/intx/inference/src/authz-extension.ts | 273 ++ vendor/intx/inference/src/correlation.ts | 68 + vendor/intx/inference/src/default-director.ts | 386 +++ vendor/intx/inference/src/director.ts | 87 + vendor/intx/inference/src/errors.ts | 115 + vendor/intx/inference/src/gates.ts | 151 + vendor/intx/inference/src/harness.ts | 1720 ++++++++++ vendor/intx/inference/src/index.ts | 74 + vendor/intx/inference/src/manifest.ts | 66 + .../intx/inference/src/providers/anthropic.ts | 1134 +++++++ .../src/providers/google-genai-files.ts | 289 ++ .../inference/src/providers/google-genai.ts | 1546 +++++++++ vendor/intx/inference/src/providers/index.ts | 71 + vendor/intx/inference/src/providers/openai.ts | 1106 +++++++ vendor/intx/inference/src/reactor.ts | 1707 ++++++++++ vendor/intx/inference/src/retry-policy.ts | 99 + vendor/intx/inference/src/sse.ts | 76 + vendor/intx/inference/src/state.ts | 135 + vendor/intx/inference/src/tool-name.ts | 128 + vendor/intx/inference/src/transform.ts | 177 + vendor/intx/inference/src/transforms/index.ts | 2 + .../intx/inference/src/transforms/size-cap.ts | 110 + vendor/intx/inference/src/turns.ts | 166 + vendor/intx/inference/tsconfig.json | 11 + vendor/intx/mail-memory/README.md | 37 + vendor/intx/mail-memory/VENDORED-FROM | 4 + vendor/intx/mail-memory/package.json | 37 + vendor/intx/mail-memory/src/index.ts | 25 + vendor/intx/mail-memory/src/mailbox.ts | 29 + vendor/intx/mail-memory/src/send.ts | 309 ++ vendor/intx/mail-memory/src/transport.ts | 786 +++++ vendor/intx/mail-memory/tsconfig.json | 11 + vendor/intx/mailbox/README.md | 30 + vendor/intx/mailbox/VENDORED-FROM | 4 + vendor/intx/mailbox/package.json | 35 + vendor/intx/mailbox/src/fetch.ts | 250 ++ vendor/intx/mailbox/src/headers.ts | 5 + vendor/intx/mailbox/src/index.ts | 11 + vendor/intx/mailbox/src/mailbox.ts | 192 ++ vendor/intx/mailbox/src/search.ts | 208 ++ vendor/intx/mailbox/src/thread.ts | 275 ++ vendor/intx/mailbox/tsconfig.json | 11 + vendor/intx/mime/README.md | 32 + vendor/intx/mime/VENDORED-FROM | 4 + vendor/intx/mime/package.json | 34 + vendor/intx/mime/src/index.ts | 44 + vendor/intx/mime/src/mail-builder.ts | 516 +++ vendor/intx/mime/src/mime.ts | 1334 ++++++++ vendor/intx/mime/src/pgp-sign.ts | 29 + vendor/intx/mime/tsconfig.json | 11 + vendor/intx/types/README.md | 62 + vendor/intx/types/VENDORED-FROM | 4 + vendor/intx/types/package.json | 78 + vendor/intx/types/src/agent-address.ts | 34 + vendor/intx/types/src/agent-data.ts | 43 + vendor/intx/types/src/approvals.ts | 38 + vendor/intx/types/src/assets.ts | 42 + vendor/intx/types/src/attachments.ts | 66 + vendor/intx/types/src/audit.ts | 49 + vendor/intx/types/src/authz.ts | 49 + vendor/intx/types/src/base64.ts | 22 + vendor/intx/types/src/base64url.ts | 22 + vendor/intx/types/src/capabilities.ts | 59 + vendor/intx/types/src/catalog.ts | 254 ++ vendor/intx/types/src/common.ts | 34 + vendor/intx/types/src/concat.ts | 19 + vendor/intx/types/src/content-type.ts | 20 + vendor/intx/types/src/credential-cipher.ts | 42 + vendor/intx/types/src/credentials.ts | 132 + vendor/intx/types/src/grant-snapshot.ts | 37 + vendor/intx/types/src/grant-wire.ts | 29 + vendor/intx/types/src/grants.ts | 114 + vendor/intx/types/src/has-code.ts | 11 + vendor/intx/types/src/hex.ts | 25 + vendor/intx/types/src/index.ts | 37 + vendor/intx/types/src/instances.ts | 77 + vendor/intx/types/src/me.ts | 60 + vendor/intx/types/src/mediated-credential.ts | 102 + vendor/intx/types/src/message-id.ts | 82 + vendor/intx/types/src/models.ts | 39 + vendor/intx/types/src/oauth-clients.ts | 47 + vendor/intx/types/src/observability.ts | 64 + vendor/intx/types/src/offerings.ts | 67 + vendor/intx/types/src/package-json.ts | 98 + vendor/intx/types/src/principals.ts | 57 + vendor/intx/types/src/providers.ts | 56 + vendor/intx/types/src/roles.ts | 21 + vendor/intx/types/src/runtime-capabilities.ts | 135 + vendor/intx/types/src/runtime.ts | 2887 +++++++++++++++++ vendor/intx/types/src/sessions.ts | 151 + vendor/intx/types/src/sidecar-allocation.ts | 32 + vendor/intx/types/src/sidecar-placement.ts | 12 + vendor/intx/types/src/sidecar.ts | 1016 ++++++ vendor/intx/types/src/signals.ts | 96 + vendor/intx/types/src/tenants.ts | 44 + vendor/intx/types/src/tool-packages.ts | 312 ++ vendor/intx/types/src/wallets.ts | 59 + vendor/intx/types/src/wire-definition-hash.ts | 82 + vendor/intx/types/src/wire-workflow.ts | 167 + vendor/intx/types/src/workflow-run-id.ts | 40 + vendor/intx/types/src/workflow-sources.ts | 74 + vendor/intx/types/src/workflows.ts | 48 + vendor/intx/types/tsconfig.json | 11 + vendor/intx/workflow-host/VENDORED-FROM | 2 +- .../src/child/supervisor-backed-transport.ts | 5 +- workflows/assistant/package.json | 4 +- workflows/attio-task-agent/package.json | 4 +- workflows/code-review/package.json | 4 +- workflows/collateral-generation/package.json | 4 +- workflows/diligence-brief/package.json | 4 +- workflows/echo/package.json | 2 +- workflows/exa-topic-watch/package.json | 4 +- workflows/granola-call/package.json | 4 +- workflows/heartbeat/package.json | 2 +- workflows/last-30-days-research/package.json | 2 +- workflows/morning-brief/package.json | 4 +- workflows/pain-point-collateral/package.json | 4 +- workflows/process-granola-call/package.json | 4 +- .../reddit-opportunity-scanner/package.json | 2 +- workflows/workbench-digest/package.json | 2 +- 209 files changed, 26541 insertions(+), 252 deletions(-) create mode 100644 vendor/intx/agent/CONVENTIONS.md create mode 100644 vendor/intx/agent/README.md create mode 100644 vendor/intx/agent/VENDORED-FROM create mode 100644 vendor/intx/agent/package.json create mode 100644 vendor/intx/agent/src/agent.ts create mode 100644 vendor/intx/agent/src/canonicalize.ts create mode 100644 vendor/intx/agent/src/default-director.ts create mode 100644 vendor/intx/agent/src/definition.ts create mode 100644 vendor/intx/agent/src/director-registry.ts create mode 100644 vendor/intx/agent/src/director-types.ts create mode 100644 vendor/intx/agent/src/director.ts create mode 100644 vendor/intx/agent/src/env-validation.ts create mode 100644 vendor/intx/agent/src/env.ts create mode 100644 vendor/intx/agent/src/index.ts create mode 100644 vendor/intx/agent/src/internal-fixtures/mail.ts create mode 100644 vendor/intx/agent/src/internal-fixtures/planner.ts create mode 100644 vendor/intx/agent/src/lock.ts create mode 100644 vendor/intx/agent/src/namespace.ts create mode 100644 vendor/intx/agent/src/send-queue.ts create mode 100644 vendor/intx/agent/src/source.ts create mode 100644 vendor/intx/agent/src/stream.ts create mode 100644 vendor/intx/agent/src/testing/audit-noop.ts create mode 100644 vendor/intx/agent/src/testing/authorize-allow.ts create mode 100644 vendor/intx/agent/src/testing/index.ts create mode 100644 vendor/intx/agent/src/tool.ts create mode 100644 vendor/intx/agent/tsconfig.json create mode 100644 vendor/intx/inference/README.md create mode 100644 vendor/intx/inference/VENDORED-FROM create mode 100644 vendor/intx/inference/package.json create mode 100644 vendor/intx/inference/src/actions.ts create mode 100644 vendor/intx/inference/src/adapter.ts create mode 100644 vendor/intx/inference/src/assembly.ts create mode 100644 vendor/intx/inference/src/audit-collector.ts create mode 100644 vendor/intx/inference/src/auth.ts create mode 100644 vendor/intx/inference/src/authz-extension.ts create mode 100644 vendor/intx/inference/src/correlation.ts create mode 100644 vendor/intx/inference/src/default-director.ts create mode 100644 vendor/intx/inference/src/director.ts create mode 100644 vendor/intx/inference/src/errors.ts create mode 100644 vendor/intx/inference/src/gates.ts create mode 100644 vendor/intx/inference/src/harness.ts create mode 100644 vendor/intx/inference/src/index.ts create mode 100644 vendor/intx/inference/src/manifest.ts create mode 100644 vendor/intx/inference/src/providers/anthropic.ts create mode 100644 vendor/intx/inference/src/providers/google-genai-files.ts create mode 100644 vendor/intx/inference/src/providers/google-genai.ts create mode 100644 vendor/intx/inference/src/providers/index.ts create mode 100644 vendor/intx/inference/src/providers/openai.ts create mode 100644 vendor/intx/inference/src/reactor.ts create mode 100644 vendor/intx/inference/src/retry-policy.ts create mode 100644 vendor/intx/inference/src/sse.ts create mode 100644 vendor/intx/inference/src/state.ts create mode 100644 vendor/intx/inference/src/tool-name.ts create mode 100644 vendor/intx/inference/src/transform.ts create mode 100644 vendor/intx/inference/src/transforms/index.ts create mode 100644 vendor/intx/inference/src/transforms/size-cap.ts create mode 100644 vendor/intx/inference/src/turns.ts create mode 100644 vendor/intx/inference/tsconfig.json create mode 100644 vendor/intx/mail-memory/README.md create mode 100644 vendor/intx/mail-memory/VENDORED-FROM create mode 100644 vendor/intx/mail-memory/package.json create mode 100644 vendor/intx/mail-memory/src/index.ts create mode 100644 vendor/intx/mail-memory/src/mailbox.ts create mode 100644 vendor/intx/mail-memory/src/send.ts create mode 100644 vendor/intx/mail-memory/src/transport.ts create mode 100644 vendor/intx/mail-memory/tsconfig.json create mode 100644 vendor/intx/mailbox/README.md create mode 100644 vendor/intx/mailbox/VENDORED-FROM create mode 100644 vendor/intx/mailbox/package.json create mode 100644 vendor/intx/mailbox/src/fetch.ts create mode 100644 vendor/intx/mailbox/src/headers.ts create mode 100644 vendor/intx/mailbox/src/index.ts create mode 100644 vendor/intx/mailbox/src/mailbox.ts create mode 100644 vendor/intx/mailbox/src/search.ts create mode 100644 vendor/intx/mailbox/src/thread.ts create mode 100644 vendor/intx/mailbox/tsconfig.json create mode 100644 vendor/intx/mime/README.md create mode 100644 vendor/intx/mime/VENDORED-FROM create mode 100644 vendor/intx/mime/package.json create mode 100644 vendor/intx/mime/src/index.ts create mode 100644 vendor/intx/mime/src/mail-builder.ts create mode 100644 vendor/intx/mime/src/mime.ts create mode 100644 vendor/intx/mime/src/pgp-sign.ts create mode 100644 vendor/intx/mime/tsconfig.json create mode 100644 vendor/intx/types/README.md create mode 100644 vendor/intx/types/VENDORED-FROM create mode 100644 vendor/intx/types/package.json create mode 100644 vendor/intx/types/src/agent-address.ts create mode 100644 vendor/intx/types/src/agent-data.ts create mode 100644 vendor/intx/types/src/approvals.ts create mode 100644 vendor/intx/types/src/assets.ts create mode 100644 vendor/intx/types/src/attachments.ts create mode 100644 vendor/intx/types/src/audit.ts create mode 100644 vendor/intx/types/src/authz.ts create mode 100644 vendor/intx/types/src/base64.ts create mode 100644 vendor/intx/types/src/base64url.ts create mode 100644 vendor/intx/types/src/capabilities.ts create mode 100644 vendor/intx/types/src/catalog.ts create mode 100644 vendor/intx/types/src/common.ts create mode 100644 vendor/intx/types/src/concat.ts create mode 100644 vendor/intx/types/src/content-type.ts create mode 100644 vendor/intx/types/src/credential-cipher.ts create mode 100644 vendor/intx/types/src/credentials.ts create mode 100644 vendor/intx/types/src/grant-snapshot.ts create mode 100644 vendor/intx/types/src/grant-wire.ts create mode 100644 vendor/intx/types/src/grants.ts create mode 100644 vendor/intx/types/src/has-code.ts create mode 100644 vendor/intx/types/src/hex.ts create mode 100644 vendor/intx/types/src/index.ts create mode 100644 vendor/intx/types/src/instances.ts create mode 100644 vendor/intx/types/src/me.ts create mode 100644 vendor/intx/types/src/mediated-credential.ts create mode 100644 vendor/intx/types/src/message-id.ts create mode 100644 vendor/intx/types/src/models.ts create mode 100644 vendor/intx/types/src/oauth-clients.ts create mode 100644 vendor/intx/types/src/observability.ts create mode 100644 vendor/intx/types/src/offerings.ts create mode 100644 vendor/intx/types/src/package-json.ts create mode 100644 vendor/intx/types/src/principals.ts create mode 100644 vendor/intx/types/src/providers.ts create mode 100644 vendor/intx/types/src/roles.ts create mode 100644 vendor/intx/types/src/runtime-capabilities.ts create mode 100644 vendor/intx/types/src/runtime.ts create mode 100644 vendor/intx/types/src/sessions.ts create mode 100644 vendor/intx/types/src/sidecar-allocation.ts create mode 100644 vendor/intx/types/src/sidecar-placement.ts create mode 100644 vendor/intx/types/src/sidecar.ts create mode 100644 vendor/intx/types/src/signals.ts create mode 100644 vendor/intx/types/src/tenants.ts create mode 100644 vendor/intx/types/src/tool-packages.ts create mode 100644 vendor/intx/types/src/wallets.ts create mode 100644 vendor/intx/types/src/wire-definition-hash.ts create mode 100644 vendor/intx/types/src/wire-workflow.ts create mode 100644 vendor/intx/types/src/workflow-run-id.ts create mode 100644 vendor/intx/types/src/workflow-sources.ts create mode 100644 vendor/intx/types/src/workflows.ts create mode 100644 vendor/intx/types/tsconfig.json diff --git a/VENDORED.md b/VENDORED.md index 48bbe1e53..d376b1999 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -22,15 +22,35 @@ never a convenience. ## Ledger -| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ---------- | ----------------- | -| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: of 38 tracked `src/` modules, 5 are byte-identical to upstream (`default-harness.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), 10 are substantially rewritten under the same name (`atomic-write.ts`, `config.ts`, `conversation-state.ts`, `index.ts`, `run-grants.ts`, `signing-keypair.ts`, `step-agent-tools.ts`, `tool-materialization.ts`, `workflow-closure-materialization.ts`, `workflow-run-pack-client.ts`), and the remaining 23 are workbench-only, including the `workflow-host-wiring/` and `workflow-substrate-factory/` module splits of upstream's single-file `workflow-host-wiring.ts` and `workflow-substrate-factory.ts`. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the exported null-principal `resolveApproval` (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the usage forward (CL-5879), pack-acceptance fixes, adopted deploy front, wire-projection writer, event-collector serialization, anchor ordering, or malformed tool-call-name sanitization (CL-6478) | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `onBodyFailure` trigger policy and its projection (CL-6326, CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | Carries no delta of its own, but must bind against the vendored `@intx/workflow` (whose `onBodyFailure` field flows through the projection it hashes); retired with the workflow delta | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164), the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435), or the body-spawn authorize/credential threading (CL-6448); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | +| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | +| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: of 38 tracked `src/` modules, 5 are byte-identical to upstream (`default-harness.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), 10 are substantially rewritten under the same name (`atomic-write.ts`, `config.ts`, `conversation-state.ts`, `index.ts`, `run-grants.ts`, `signing-keypair.ts`, `step-agent-tools.ts`, `tool-materialization.ts`, `workflow-closure-materialization.ts`, `workflow-run-pack-client.ts`), and the remaining 23 are workbench-only, including the `workflow-host-wiring/` and `workflow-substrate-factory/` module splits of upstream's single-file `workflow-host-wiring.ts` and `workflow-substrate-factory.ts`. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; no local delta; retired by the next `@intx/agent` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the exported null-principal `resolveApproval` (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the usage forward (CL-5879), pack-acceptance fixes, adopted deploy front, wire-projection writer, event-collector serialization, anchor ordering, or malformed tool-call-name sanitization (CL-6478) | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); one local delta: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the `onBodyFailure` trigger policy and its projection (CL-6326, CL-6324); retired when upstream absorbs the delta | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | Carries no delta of its own, but must bind against the vendored `@intx/workflow` (whose `onBodyFailure` field flows through the projection it hashes); retired with the workflow delta | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164), the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435), or the body-spawn authorize/credential threading (CL-6448); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | + +The re-pin to `a8bc06ae` (upstream `origin/main`, 2026-08-27, 72 commits past +`v0.3.0`) is landing row by row; npm is still `0.3.0`, so every tree an +already re-pinned tree imports at a newer API is vendored too, all at the +same commit — a vendored tree never mixes pins. The root `package.json` +`overrides` therefore point each vendored name at `workspace:*` (so the +published `@intx/harness`, `@intx/hub-agent`, `@intx/tool-packaging`, +`@intx/authz`, … resolve their own `@intx/*` dependencies onto the vendored +copies instead of a second npm copy) and keep the unchanged names on `0.3.0`. +`vendor/intx/workflow-host` (still at `b5580a02` until its own re-pin) +carries one bridging edit against the re-pinned `@intx/types`: its +supervisor-backed transport's `expunge` stub returns +`Promise<{ expungedUids: number[] }>` (upstream `bcabb1f8`); it disappears +with that tree's re-pin. The pinned commit `b5580a02` is upstream's `v0.3.0` release tag, 16 commits past the previous pin `4ed8baf4`: a workflow-host supervisor diff --git a/apps/hub/package.json b/apps/hub/package.json index 9dad84cad..498318498 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -60,8 +60,8 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/mime": "0.3.0", - "@intx/types": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/access-policy": "workspace:*", diff --git a/apps/sidecar/package.json b/apps/sidecar/package.json index d11a8cae1..0e841dc02 100644 --- a/apps/sidecar/package.json +++ b/apps/sidecar/package.json @@ -20,18 +20,18 @@ "@corbits/error-sink": "workspace:*", "@corbits/ollama-adapter": "workspace:*", "@corbits/workflow-host-actions": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/harness": "0.3.0", "@intx/hub-agent": "0.3.0", "@intx/hub-sessions": "workspace:*", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/log": "0.3.0", - "@intx/mail-memory": "0.3.0", + "@intx/mail-memory": "workspace:*", "@intx/storage-isogit": "0.3.0", "@intx/tool-packaging": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@intx/workflow-host": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index 4646db72a..166d5d1fa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,7 +43,7 @@ "@corbits/url-path": "workspace:*", "@corbits/workflow-catalog": "workspace:*", "@corbits/icons": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "catalog:", diff --git a/bun.lock b/bun.lock index 09ffcdbb6..6e5f5e1b9 100644 --- a/bun.lock +++ b/bun.lock @@ -79,8 +79,8 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/mime": "0.3.0", - "@intx/types": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/access-policy": "workspace:*", @@ -112,18 +112,18 @@ "@corbits/error-sink": "workspace:*", "@corbits/ollama-adapter": "workspace:*", "@corbits/workflow-host-actions": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/harness": "0.3.0", "@intx/hub-agent": "0.3.0", "@intx/hub-sessions": "workspace:*", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/log": "0.3.0", - "@intx/mail-memory": "0.3.0", + "@intx/mail-memory": "workspace:*", "@intx/storage-isogit": "0.3.0", "@intx/tool-packaging": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@intx/workflow-host": "workspace:*", @@ -164,7 +164,7 @@ "@corbits/text-diff": "workspace:*", "@corbits/url-path": "workspace:*", "@corbits/workflow-catalog": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "catalog:", @@ -214,12 +214,12 @@ "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-freeze": "workspace:*", "@corbits/workflow-source": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", @@ -238,8 +238,8 @@ "name": "@corbits/agent-directory-tools", "version": "0.0.4", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -271,8 +271,8 @@ "version": "0.0.1", "dependencies": { "@corbits/workflow-source": "workspace:*", - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "arktype": "catalog:", @@ -289,7 +289,7 @@ "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-sessions": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -322,7 +322,7 @@ "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -363,7 +363,7 @@ "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -394,7 +394,7 @@ "version": "0.0.1", "dependencies": { "@corbits/api-query": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -406,8 +406,8 @@ "name": "@corbits/capability-tools", "version": "0.0.3", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -419,8 +419,8 @@ "name": "@corbits/catalog-tools", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -444,7 +444,7 @@ "@corbits/url-path": "workspace:*", "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-source": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", @@ -452,8 +452,8 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/mime": "0.3.0", - "@intx/types": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@workbench/connections": "workspace:*", @@ -490,7 +490,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "^20.11.2", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@types/bun": "catalog:", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.1", @@ -504,7 +504,7 @@ "workbench": "./src/index.ts", }, "dependencies": { - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", }, @@ -573,7 +573,7 @@ "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", @@ -590,8 +590,8 @@ "name": "@corbits/connections-tools", "version": "0.0.5", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@workbench/connections": "workspace:*", "arktype": "catalog:", }, @@ -621,7 +621,7 @@ "version": "0.0.1", "dependencies": { "@intx/harness": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", }, "devDependencies": { "@types/bun": "catalog:", @@ -689,12 +689,12 @@ "@corbits/routines": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workflow-catalog": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/connections": "workspace:*", @@ -727,7 +727,7 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow-deploy": "workspace:*", "drizzle-orm": "catalog:", }, @@ -746,8 +746,8 @@ "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", - "@intx/mime": "0.3.0", - "@intx/types": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@workbench/connections": "workspace:*", @@ -764,8 +764,8 @@ "name": "@corbits/github-tools", "version": "0.0.6", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -789,8 +789,8 @@ "name": "@corbits/granola-tools", "version": "0.0.4", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -816,8 +816,8 @@ "@corbits/workbench-digest-workflow": "workspace:*", "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-source": "workspace:*", - "@intx/inference": "0.3.0", - "@intx/types": "0.3.0", + "@intx/inference": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -865,7 +865,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/inference-catalog": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -880,7 +880,7 @@ "name": "@corbits/inference-settings", "version": "0.0.1", "dependencies": { - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", }, @@ -898,7 +898,7 @@ "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -913,8 +913,8 @@ "name": "@corbits/interaction-tools", "version": "0.0.2", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -926,8 +926,8 @@ "name": "@corbits/jimmy-agent", "version": "0.0.2", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -939,8 +939,8 @@ "name": "@corbits/linear-tools", "version": "0.0.4", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -957,7 +957,7 @@ "name": "@corbits/longevity-sim", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -970,8 +970,8 @@ "name": "@corbits/mcp-tools", "version": "0.0.8", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "arktype": "catalog:", }, @@ -998,8 +998,8 @@ "name": "@corbits/memory-tools", "version": "0.0.4", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1025,7 +1025,7 @@ "dependencies": { "@intx/authz": "0.3.0", "@intx/hub-common": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "postgres": "catalog:", @@ -1039,8 +1039,8 @@ "name": "@corbits/ollama-adapter", "version": "0.0.1", "dependencies": { - "@intx/inference": "0.3.0", - "@intx/types": "0.3.0", + "@intx/inference": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1055,7 +1055,7 @@ "@corbits/error-sink": "workspace:*", "@intx/crypto": "0.3.0", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/access-policy": "workspace:*", "@workbench/connections": "workspace:*", "@workbench/hub-client": "workspace:*", @@ -1138,8 +1138,8 @@ "name": "@corbits/reddit-tools", "version": "0.0.2", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1173,8 +1173,8 @@ "name": "@corbits/routines-tools", "version": "0.0.5", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1206,7 +1206,7 @@ "@corbits/folded-runs": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1234,8 +1234,8 @@ "name": "@corbits/scout-agent", "version": "0.0.2", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1255,7 +1255,7 @@ "@corbits/inference-settings": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/workflow-catalog": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/connections": "workspace:*", "arktype": "catalog:", "react": "^19.2.0", @@ -1291,7 +1291,7 @@ "dependencies": { "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1323,8 +1323,8 @@ "name": "@corbits/skills-tools", "version": "0.0.6", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1372,9 +1372,9 @@ "name": "@corbits/tool-registry-publish", "version": "0.0.1", "dependencies": { - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "tar": "catalog:", }, @@ -1387,8 +1387,8 @@ "name": "@corbits/tools-skills", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1419,8 +1419,8 @@ "name": "@corbits/web-search-tools", "version": "0.0.3", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1438,7 +1438,7 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", @@ -1478,7 +1478,7 @@ "dependencies": { "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "postgres": "catalog:", @@ -1492,10 +1492,10 @@ "name": "@corbits/workflow-freeze", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-sessions": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "arktype": "catalog:", @@ -1513,7 +1513,7 @@ "version": "0.0.1", "dependencies": { "@intx/hub-sessions": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1531,6 +1531,21 @@ "typescript": "catalog:", }, }, + "vendor/intx/agent": { + "name": "@intx/agent", + "version": "0.3.0", + "dependencies": { + "@intx/inference": "workspace:*", + "@intx/log": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "vendor/intx/db": { "name": "@intx/db", "version": "0.3.0", @@ -1609,6 +1624,75 @@ "typescript": "catalog:", }, }, + "vendor/intx/inference": { + "name": "@intx/inference", + "version": "0.3.0", + "dependencies": { + "@intx/log": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, + "vendor/intx/mail-memory": { + "name": "@intx/mail-memory", + "version": "0.3.0", + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/log": "0.3.0", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, + "vendor/intx/mailbox": { + "name": "@intx/mailbox", + "version": "0.3.0", + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, + "vendor/intx/mime": { + "name": "@intx/mime", + "version": "0.3.0", + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, + "vendor/intx/types": { + "name": "@intx/types", + "version": "0.3.0", + "dependencies": { + "arktype": "catalog:", + "semver": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/semver": "catalog:", + "typescript": "catalog:", + }, + }, "vendor/intx/workflow": { "name": "@intx/workflow", "version": "0.3.0", @@ -1665,8 +1749,8 @@ "version": "0.0.1", "dependencies": { "@corbits/catalog-tools": "workspace:*", - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", }, "devDependencies": { @@ -1682,8 +1766,8 @@ "name": "@corbits/attio-task-agent-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1697,8 +1781,8 @@ "version": "0.0.1", "dependencies": { "@corbits/code-review": "workspace:*", - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", }, "devDependencies": { @@ -1710,8 +1794,8 @@ "name": "@corbits/collateral-generation-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1724,8 +1808,8 @@ "name": "@corbits/diligence-brief-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1738,7 +1822,7 @@ "name": "@corbits/echo-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", }, "devDependencies": { @@ -1750,8 +1834,8 @@ "name": "@corbits/exa-topic-watch-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1764,8 +1848,8 @@ "name": "@corbits/granola-call-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1778,7 +1862,7 @@ "name": "@corbits/heartbeat-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", }, "devDependencies": { @@ -1790,7 +1874,7 @@ "name": "@corbits/last-30-days-research-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1803,8 +1887,8 @@ "name": "@corbits/morning-brief-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1818,8 +1902,8 @@ "name": "@corbits/pain-point-collateral-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1832,8 +1916,8 @@ "name": "@corbits/process-granola-call-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1846,7 +1930,7 @@ "name": "@corbits/reddit-opportunity-scanner-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1859,7 +1943,7 @@ "name": "@corbits/workbench-digest-workflow", "version": "0.0.1", "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", }, "devDependencies": { @@ -1873,7 +1957,7 @@ "@corbits/mailbox", ], "overrides": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "0.3.0", @@ -1882,15 +1966,16 @@ "@intx/hub-api": "0.3.0", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "0.3.0", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/inference-catalog": "0.3.0", "@intx/log": "0.3.0", - "@intx/mail-memory": "0.3.0", - "@intx/mime": "0.3.0", + "@intx/mail-memory": "workspace:*", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", "@intx/pack-transport": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/tool-packaging": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "0.3.0", "@intx/workflow-deploy": "0.3.0", "@intx/workflow-host": "0.3.0", @@ -1903,6 +1988,7 @@ "@tanstack/react-query": "^5.101.4", "@types/bun": "^1.3.9", "@types/semver": "^7.7.1", + "@types/ssri": "^7.1.5", "arktype": "^2.2.0", "better-auth": "^1.4.18", "drizzle-orm": "^0.45.1", @@ -2279,7 +2365,7 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@intx/agent": ["@intx/agent@0.3.0", "", { "dependencies": { "@intx/inference": "0.3.0", "@intx/log": "0.3.0", "@intx/mime": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-a7cgmH8FSsGQ7BpLKQhEwZYRfofWh3lWEGgKFsk53zdgjndtDfqAf2I64QMPAqC2Y2Npag/z1NA5lEsIqAGvPg=="], + "@intx/agent": ["@intx/agent@workspace:vendor/intx/agent"], "@intx/authz": ["@intx/authz@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-kyIKQAVjfB8H84mES0QA4cNnYfpPoyLmQITVBxiuXv1VO/K74TQZnCDpoo0IL7oFk2q7bbDXSAEE4ubMBVTHzA=="], @@ -2297,15 +2383,17 @@ "@intx/hub-sessions": ["@intx/hub-sessions@workspace:vendor/intx/hub-sessions"], - "@intx/inference": ["@intx/inference@0.3.0", "", { "dependencies": { "@intx/log": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-2DxqWRp5cSziXgDXPomSy0HTLVesBCtZErsKXYFnliTwYM6U+GRKk5rzwWMMgMWMFbtUaiogPExcNQYBYmfjQA=="], + "@intx/inference": ["@intx/inference@workspace:vendor/intx/inference"], "@intx/inference-catalog": ["@intx/inference-catalog@0.3.0", "", {}, "sha512-iSimimSoIALXWWNGOG+jYmJQEQgXnWtVREB5DkSCZum/EhSTZBESUn9+aT+poB8VZAmMbeExxaiKjWWpoPM3GA=="], "@intx/log": ["@intx/log@0.3.0", "", { "dependencies": { "@logtape/hono": "^2.0.2", "@logtape/logtape": "^2.0.2" }, "peerDependencies": { "hono": "^4.0.0" }, "optionalPeers": ["hono"] }, "sha512-iooPSZjiEUO1A91X5STRhOAk+2RprliB4nbriV/dOOz86kuxZ60y5T8ISQ5Jaj1US870gntN6QwnefY38aWi2Q=="], - "@intx/mail-memory": ["@intx/mail-memory@0.3.0", "", { "dependencies": { "@intx/crypto": "0.3.0", "@intx/log": "0.3.0", "@intx/mime": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-A0bEHi0tbGxZ9Wpvt9YwrbGkekirBJvKtzgG+dm3/tLkLfWuaEFHTJ8IuSnR3CRyjeLcNw8ItuznXRFs4WfgcA=="], + "@intx/mail-memory": ["@intx/mail-memory@workspace:vendor/intx/mail-memory"], + + "@intx/mailbox": ["@intx/mailbox@workspace:vendor/intx/mailbox"], - "@intx/mime": ["@intx/mime@0.3.0", "", { "dependencies": { "@intx/crypto": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-jpKZZpfWRQJ6fI8BEpHnr83N+1ZHrnOMY978B/6bgcjFn3qtm1f3KiR0yaQ8eF3r1VF327r3H2qiCCx0OFN9Ow=="], + "@intx/mime": ["@intx/mime@workspace:vendor/intx/mime"], "@intx/pack-transport": ["@intx/pack-transport@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-EQ1wM16323aIFhw3Ak6HwB8YqzKSAfRmJUwjIfqyXk2t6TGv4grws6dIs7Yau1dc372s/SO+GhvFaKgvI6XTWA=="], @@ -2313,7 +2401,7 @@ "@intx/tool-packaging": ["@intx/tool-packaging@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/log": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29", "isomorphic-git": "^1.27.2", "npm-package-arg": "^12.0.2", "npm-pick-manifest": "^10.0.0", "npm-registry-fetch": "^19.0.0", "semver": "^7.7.2", "ssri": "^12.0.0", "tar": "^7.5.1" } }, "sha512-Lr/xdEjXTindJRm+eITvfeqpW/yl71thYjr3HXl41GJ9wpuOZPCjm9vBoRVKhUSwmIUKBYbm2VplXCCXaS7f4g=="], - "@intx/types": ["@intx/types@0.3.0", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-PJ+v3IhtfZ4J7ZqJ8E39TteRMnC6Y503Q0JeCJdskkK+jhcjx01z7TJSozucagauAlpyh8Hn/9CyWFl53Ji0DQ=="], + "@intx/types": ["@intx/types@workspace:vendor/intx/types"], "@intx/workflow": ["@intx/workflow@workspace:vendor/intx/workflow"], @@ -2549,6 +2637,8 @@ "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "@types/ssri": ["@types/ssri@7.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -3439,7 +3529,7 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3463,9 +3553,7 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="], - - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], diff --git a/package.json b/package.json index eed827ae2..273cd082c 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "postgres": "^3.4.8", "semver": "^7.7.2", "ssri": "^12.0.0", - "tar": "^7.5.1" + "tar": "^7.5.1", + "@types/ssri": "^7.1.5" }, "trustedDependencies": [ "@corbits/react-ui", @@ -88,7 +89,7 @@ }, "overrides": { "arktype": "^2.2.0", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "0.3.0", @@ -97,19 +98,20 @@ "@intx/hub-api": "0.3.0", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "0.3.0", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/inference-catalog": "0.3.0", "@intx/log": "0.3.0", - "@intx/mail-memory": "0.3.0", - "@intx/mime": "0.3.0", + "@intx/mail-memory": "workspace:*", + "@intx/mime": "workspace:*", "@intx/pack-transport": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/tool-packaging": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "0.3.0", "@intx/workflow-deploy": "0.3.0", "@intx/workflow-host": "0.3.0", "better-auth": "1.6.29", - "hono": "4.13.3" + "hono": "4.13.3", + "@intx/mailbox": "workspace:*" } } diff --git a/packages/agent-directory-tools/package.json b/packages/agent-directory-tools/package.json index c7640058c..1fede8d7d 100644 --- a/packages/agent-directory-tools/package.json +++ b/packages/agent-directory-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/agent-directory/package.json b/packages/agent-directory/package.json index 2ee6d1aa6..4b2abb0ff 100644 --- a/packages/agent-directory/package.json +++ b/packages/agent-directory/package.json @@ -20,12 +20,12 @@ "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-freeze": "workspace:*", "@corbits/workflow-source": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json index 7aa4106ab..07dedd25a 100644 --- a/packages/agent-runtime/package.json +++ b/packages/agent-runtime/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@corbits/workflow-source": "workspace:*", - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "arktype": "catalog:" diff --git a/packages/agent-workflow-authoring/package.json b/packages/agent-workflow-authoring/package.json index c483f06fb..b87f531e8 100644 --- a/packages/agent-workflow-authoring/package.json +++ b/packages/agent-workflow-authoring/package.json @@ -16,7 +16,7 @@ "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-sessions": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/approvals/package.json b/packages/approvals/package.json index 060cc50a0..3c18a0aa3 100644 --- a/packages/approvals/package.json +++ b/packages/approvals/package.json @@ -17,7 +17,7 @@ "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/artifacts-hub/package.json b/packages/artifacts-hub/package.json index bd2b6206b..d46424d77 100644 --- a/packages/artifacts-hub/package.json +++ b/packages/artifacts-hub/package.json @@ -19,7 +19,7 @@ "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/bench-ui/package.json b/packages/bench-ui/package.json index 526356c9a..9df91307f 100644 --- a/packages/bench-ui/package.json +++ b/packages/bench-ui/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@corbits/api-query": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/capability-tools/package.json b/packages/capability-tools/package.json index 54e0883c0..5decb7591 100644 --- a/packages/capability-tools/package.json +++ b/packages/capability-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index 04d867c70..13f94eae7 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index 753f8ae7c..7fbad1f90 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -33,7 +33,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "^20.11.2", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@types/bun": "catalog:", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.1", diff --git a/packages/chat-ui/test/inference-preamble-drift.test.ts b/packages/chat-ui/test/inference-preamble-drift.test.ts index 715e28352..bdeccac97 100644 --- a/packages/chat-ui/test/inference-preamble-drift.test.ts +++ b/packages/chat-ui/test/inference-preamble-drift.test.ts @@ -6,21 +6,22 @@ // vanishing. See inference-failure.ts's own module comment for why this // stays a prose match rather than a structured read: a reply reaches the // chat timeline as a plain `text` part with no metadata by the time this -// module ever sees it. The published package ships compiled dist only, -// so the guard reads the compiled director — the string literals survive -// compilation verbatim. +// module ever sees it. The guard reads the director module sitting beside +// the package's resolved entry point, whatever extension the install +// ships (vendored TypeScript source or a published compiled dist). import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, extname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { CLASSIFIED_INFERENCE_FAILURE_PREAMBLES } from "../src/inference-failure"; test("chat-ui's classified-failure preambles match the published director's exact strings", () => { - const distDir = dirname( - fileURLToPath(import.meta.resolve("@intx/inference")), + const entry = fileURLToPath(import.meta.resolve("@intx/inference")); + const director = readFileSync( + join(dirname(entry), `default-director${extname(entry)}`), + "utf8", ); - const director = readFileSync(join(distDir, "default-director.js"), "utf8"); for (const preamble of CLASSIFIED_INFERENCE_FAILURE_PREAMBLES) { expect(director).toContain(`"${preamble}"`); } diff --git a/packages/chat/package.json b/packages/chat/package.json index 4596e789a..20ec8cb22 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -36,7 +36,7 @@ "@corbits/turn-artifacts": "workspace:*", "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-source": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", @@ -44,8 +44,8 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/mime": "0.3.0", - "@intx/types": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@corbits/url-path": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index dea5bc788..fb11aa48b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,7 +16,7 @@ "test": "bun test" }, "dependencies": { - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:" }, diff --git a/packages/connections-tools/package.json b/packages/connections-tools/package.json index 26ab52233..84201eb50 100644 --- a/packages/connections-tools/package.json +++ b/packages/connections-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@workbench/connections": "workspace:*", "arktype": "catalog:" }, diff --git a/packages/connections/package.json b/packages/connections/package.json index 57f5ea449..897ed8882 100644 --- a/packages/connections/package.json +++ b/packages/connections/package.json @@ -24,7 +24,7 @@ "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", diff --git a/packages/credential-providers/package.json b/packages/credential-providers/package.json index d42e1e29f..cdffd792b 100644 --- a/packages/credential-providers/package.json +++ b/packages/credential-providers/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@intx/harness": "0.3.0", - "@intx/types": "0.3.0" + "@intx/types": "workspace:*" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/evals/package.json b/packages/evals/package.json index e8e28562d..3e62e5c51 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -18,12 +18,12 @@ "@corbits/routines": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workflow-catalog": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/connections": "workspace:*", diff --git a/packages/folded-run-one-shot/package.json b/packages/folded-run-one-shot/package.json index a82d766e3..456260a31 100644 --- a/packages/folded-run-one-shot/package.json +++ b/packages/folded-run-one-shot/package.json @@ -20,7 +20,7 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow-deploy": "workspace:*", "drizzle-orm": "catalog:" }, diff --git a/packages/folded-runs/package.json b/packages/folded-runs/package.json index 8b5cceff3..f1fa976aa 100644 --- a/packages/folded-runs/package.json +++ b/packages/folded-runs/package.json @@ -20,8 +20,8 @@ "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", - "@intx/mime": "0.3.0", - "@intx/types": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@workbench/connections": "workspace:*", diff --git a/packages/github-tools/package.json b/packages/github-tools/package.json index a3afb0709..b91dba576 100644 --- a/packages/github-tools/package.json +++ b/packages/github-tools/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/granola-tools/package.json b/packages/granola-tools/package.json index 8b4e4d68a..e3ef0a44d 100644 --- a/packages/granola-tools/package.json +++ b/packages/granola-tools/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/hub-client/package.json b/packages/hub-client/package.json index beb5f8de7..126adee17 100644 --- a/packages/hub-client/package.json +++ b/packages/hub-client/package.json @@ -25,8 +25,8 @@ "@corbits/workbench-digest-workflow": "workspace:*", "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-source": "workspace:*", - "@intx/inference": "0.3.0", - "@intx/types": "0.3.0", + "@intx/inference": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/inference-catalog/package.json b/packages/inference-catalog/package.json index 2bf187615..e0840a392 100644 --- a/packages/inference-catalog/package.json +++ b/packages/inference-catalog/package.json @@ -22,7 +22,7 @@ "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/inference-catalog": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/inference-settings/package.json b/packages/inference-settings/package.json index 931d7e4d3..7117d9aa9 100644 --- a/packages/inference-settings/package.json +++ b/packages/inference-settings/package.json @@ -15,7 +15,7 @@ "test": "bun test" }, "dependencies": { - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:" }, diff --git a/packages/insights/package.json b/packages/insights/package.json index 4e8ea9327..41b5a0e03 100644 --- a/packages/insights/package.json +++ b/packages/insights/package.json @@ -20,7 +20,7 @@ "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/interaction-tools/package.json b/packages/interaction-tools/package.json index de4066011..426bdeecd 100644 --- a/packages/interaction-tools/package.json +++ b/packages/interaction-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/jimmy-agent/package.json b/packages/jimmy-agent/package.json index e5b887cad..469e26749 100644 --- a/packages/jimmy-agent/package.json +++ b/packages/jimmy-agent/package.json @@ -21,8 +21,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/linear-tools/package.json b/packages/linear-tools/package.json index 35d5d7cce..13798c765 100644 --- a/packages/linear-tools/package.json +++ b/packages/linear-tools/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/longevity-sim/package.json b/packages/longevity-sim/package.json index 1c9a9b68f..74c97de46 100644 --- a/packages/longevity-sim/package.json +++ b/packages/longevity-sim/package.json @@ -13,7 +13,7 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/packages/mcp-tools/package.json b/packages/mcp-tools/package.json index 8964f3e4f..4f829fcc2 100644 --- a/packages/mcp-tools/package.json +++ b/packages/mcp-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "arktype": "catalog:" }, diff --git a/packages/memory-tools/package.json b/packages/memory-tools/package.json index 419b5c8a7..3336aab13 100644 --- a/packages/memory-tools/package.json +++ b/packages/memory-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/notify/package.json b/packages/notify/package.json index 570332b90..5361c6448 100644 --- a/packages/notify/package.json +++ b/packages/notify/package.json @@ -17,7 +17,7 @@ "dependencies": { "@intx/authz": "0.3.0", "@intx/hub-common": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "postgres": "catalog:" diff --git a/packages/ollama-adapter/package.json b/packages/ollama-adapter/package.json index 2bca9647b..572f16bc7 100644 --- a/packages/ollama-adapter/package.json +++ b/packages/ollama-adapter/package.json @@ -14,8 +14,8 @@ "test": "bun test" }, "dependencies": { - "@intx/inference": "0.3.0", - "@intx/types": "0.3.0", + "@intx/inference": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json index d4930ed2e..e0c9da7ba 100644 --- a/packages/onboarding/package.json +++ b/packages/onboarding/package.json @@ -17,7 +17,7 @@ "@corbits/error-sink": "workspace:*", "@intx/crypto": "0.3.0", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/access-policy": "workspace:*", "@workbench/connections": "workspace:*", "@workbench/hub-client": "workspace:*", diff --git a/packages/reddit-tools/package.json b/packages/reddit-tools/package.json index 110fd3c4a..430ab9b1c 100644 --- a/packages/reddit-tools/package.json +++ b/packages/reddit-tools/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/routines-tools/package.json b/packages/routines-tools/package.json index bb781af1d..30b2a4db0 100644 --- a/packages/routines-tools/package.json +++ b/packages/routines-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/run-scope/package.json b/packages/run-scope/package.json index 8b5e3f4d8..f8c463ca6 100644 --- a/packages/run-scope/package.json +++ b/packages/run-scope/package.json @@ -16,7 +16,7 @@ "@corbits/folded-runs": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/scout-agent/package.json b/packages/scout-agent/package.json index c55403f65..81f6f446e 100644 --- a/packages/scout-agent/package.json +++ b/packages/scout-agent/package.json @@ -14,8 +14,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/settings-ui/package.json b/packages/settings-ui/package.json index 4f748c811..53073b9cd 100644 --- a/packages/settings-ui/package.json +++ b/packages/settings-ui/package.json @@ -23,7 +23,7 @@ "@corbits/inference-settings": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/workflow-catalog": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@workbench/connections": "workspace:*", "arktype": "catalog:", "@corbits/icons": "workspace:*", diff --git a/packages/sidecar-placement/package.json b/packages/sidecar-placement/package.json index b7bf9e39e..c83013244 100644 --- a/packages/sidecar-placement/package.json +++ b/packages/sidecar-placement/package.json @@ -15,7 +15,7 @@ "dependencies": { "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9" diff --git a/packages/skills-tools/package.json b/packages/skills-tools/package.json index d2ed71e82..0085472be 100644 --- a/packages/skills-tools/package.json +++ b/packages/skills-tools/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/tool-registry-publish/package.json b/packages/tool-registry-publish/package.json index 98d57172a..b2f399a6b 100644 --- a/packages/tool-registry-publish/package.json +++ b/packages/tool-registry-publish/package.json @@ -14,9 +14,9 @@ "check:freshness": "bun src/freshness-check.ts" }, "dependencies": { - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "tar": "catalog:" }, diff --git a/packages/tools-skills/package.json b/packages/tools-skills/package.json index 6e63ab5cb..22153c5b0 100644 --- a/packages/tools-skills/package.json +++ b/packages/tools-skills/package.json @@ -13,8 +13,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/web-search-tools/package.json b/packages/web-search-tools/package.json index 8f0e6ace4..5815ce677 100644 --- a/packages/web-search-tools/package.json +++ b/packages/web-search-tools/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/webhook-triggers/package.json b/packages/webhook-triggers/package.json index 5b3c5d759..03a9bc8ec 100644 --- a/packages/webhook-triggers/package.json +++ b/packages/webhook-triggers/package.json @@ -20,7 +20,7 @@ "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "hono": "^4.11.9", diff --git a/packages/workflow-deploy-source/package.json b/packages/workflow-deploy-source/package.json index 56a499896..3c6711787 100644 --- a/packages/workflow-deploy-source/package.json +++ b/packages/workflow-deploy-source/package.json @@ -16,7 +16,7 @@ "dependencies": { "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "postgres": "catalog:" diff --git a/packages/workflow-freeze/package.json b/packages/workflow-freeze/package.json index 920c110c8..8b9e04b81 100644 --- a/packages/workflow-freeze/package.json +++ b/packages/workflow-freeze/package.json @@ -13,10 +13,10 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-sessions": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "arktype": "catalog:", diff --git a/packages/workflow-host-actions/package.json b/packages/workflow-host-actions/package.json index 8361bc935..12266c7db 100644 --- a/packages/workflow-host-actions/package.json +++ b/packages/workflow-host-actions/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@intx/hub-sessions": "workspace:*", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index f260a5558..e1cc18b21 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -14,12 +14,18 @@ # drift: editing a vendored tree means updating this hash and the # package's VENDORED-FROM delta line in the same change. apps/sidecar | sawyer | 2026-09-19 +vendor/intx/agent | sawyer | 2026-10-26 | d0d56d9f452b78f4b541ad8f4e89f975e8069446bb98f2c8097b90de4b020243 vendor/intx/db | sawyer | 2026-09-19 | 642b29735a7e36a1d3decdf8af26c33fa91e0989720323addb4c2f786ae88a18 vendor/intx/hub-api | sawyer | 2026-09-19 | f93a383cb5d6acdf50a461b43e4c8991dbdf7e13d34a4e5598e556eaee66308b vendor/intx/hub-sessions | sawyer | 2026-09-19 | df5f1d275e197668851c53f58c51182d8bc455f3585e2ed65784d3687a856001 +vendor/intx/inference | sawyer | 2026-10-26 | 77fec29b078e8d03e686747c70e6b62ac1fd1434db0fb2c1e12e84b6dc71465f +vendor/intx/mail-memory | sawyer | 2026-10-26 | 9f3601a7fb22e2d1c63daa976f3afccbd79af2187c155a0080c0d60c82450b92 +vendor/intx/mailbox | sawyer | 2026-10-26 | d36d7ffcc32018571276e4922a8c2714b7ee0bb5deb80b01e73859245975d4c6 +vendor/intx/mime | sawyer | 2026-10-26 | d02e5f8f1429eac7c27d3a37eec31111f8a1053c91fbfae77ac58a0d63c823ed +vendor/intx/types | sawyer | 2026-10-26 | ec1de14b859007b4db137da1533d4ce79d11024ad69c8b36938017319d6d8e86 vendor/intx/workflow | sawyer | 2026-09-19 | 34628e7bbd0587f131a07e3a206141983881106963a20ab607e68aeed1135593 vendor/intx/workflow-deploy | sawyer | 2026-09-19 | 95711adf282180852b0daec1cac39d00a4dc24aff15f9a515e07eb3d2ca749f9 -vendor/intx/workflow-host | sawyer | 2026-09-19 | 6e6717e784cc55035a595320b2b8e6ea01b49ac42d4c77b444a0dc59e354b8d0 +vendor/intx/workflow-host | sawyer | 2026-09-19 | 48ae3e34c6f14b99a3a940ede98b119e52dfe7410a86f644d3b78cf6e7d44f4d packages/folded-runs | sawyer | 2026-11-01 diff --git a/vendor/intx/agent/CONVENTIONS.md b/vendor/intx/agent/CONVENTIONS.md new file mode 100644 index 000000000..082f2b430 --- /dev/null +++ b/vendor/intx/agent/CONVENTIONS.md @@ -0,0 +1,172 @@ +# `@intx/agent` conventions + +This document specifies the conventions external packages follow when +they contribute tools to an agent runtime. The runtime (`@intx/agent`) +consumes anything that fits these shapes; this file fixes the shapes. + +## Tool-package convention + +A package that contributes tools to an agent declares its tool entry in +`package.json` via the `interchange.tools` field: + +```json +{ + "name": "@vendor/my-tools", + "version": "1.2.3", + "interchange": { + "tools": "./dist/tools.js" + } +} +``` + +The path resolves against the package root. The module it names is the +**tool entry module**. + +### Tool entry module shape + +The tool entry module's **named exports** are `AnnotatedToolFactory` +values, built via `defineTool({ id, requires?, definitions, factory })`: + +```ts +import { defineTool } from "@intx/agent"; + +export const search = defineTool({ + id: "@vendor/my-tools/search", + requires: ["mail.transport"], + definitions: [{ name: SEARCH_DEFINITION.name }], + factory: (env) => ({ + definitions: [SEARCH_DEFINITION], + run: makeSearchRunner(env), + }), +}); + +export const fetch = defineTool({ + id: "@vendor/my-tools/fetch", + definitions: [{ name: FETCH_DEFINITION.name }], + factory: (env) => ({ + definitions: [FETCH_DEFINITION], + run: makeFetchRunner(env), + }), +}); +``` + +`definitions` statically declares the tool names the factory +contributes so callers (e.g. the deploy-time capability walk) can +enumerate them without instantiating the factory. + +A package may export one or many factories. Each factory's `id` must be +package-namespaced (`@vendor/pkg/name` or `pkg/name`); `defineTool` +enforces this via `validateNamespacedId`. + +The default export is not consumed. Any non-`AnnotatedToolFactory` +named export is ignored by the loader. The loader rejects an entry +module that has no `AnnotatedToolFactory` named exports. + +### Per-instance isolation + +A factory is invoked **once per agent instance**, with an env scoped to +that instance. The bundle it returns — and any handler state closed +over by the bundle's `run` — belongs to that one instance. + +The underlying ESM module is **safely shared** across instances on the +same process. Mutable per-instance state lives in handler closures +produced at factory invocation time, not in module-level bindings. A +package author who needs per-instance state assigns it inside `factory` +(closures captured by the returned `run`), never at module top level. + +This means two agent instances on one sidecar can load the same +`package@version` without sharing a mutable module cache, even though +the module graph itself is shared. The factory is the isolation +boundary; the module is not. + +### Tool-name namespacing + +The loader synthesizes the model-facing tool name as +`:`. Package authors write bare tool names inside +the `ToolDefinition` (e.g. `read_file`); the loader prefixes with the +bundle's `id`. + +Grants in the existing authz system match the matching shape: +`tool:/`. The grant evaluator does not need to +know about packages. + +The model-facing form uses `:` and the grant form uses `/` on +purpose: the model never sees the `tool:` resource prefix the grant +evaluator works with, so reusing `:` in both would either force the +model-facing form to also carry the `tool:` prefix (verbose, leaks +authz internals) or have the grant form drop its `tool:` discriminator +(loses the prefix that lets the evaluator route resources by kind). +The two-character split keeps each form unambiguous within its own +layer. + +### Audit provenance + +Every tool invocation is tagged with the providing bundle's `id`. The +package does not need to participate; the loader records provenance +against the bundle it dispatched to. + +## Env requirements + +A factory's `requires: readonly string[]` enumerates the env keys it +reads at construction time, beyond `BaseEnv`'s core fields. The agent +runtime's `validateEnv` (in `@intx/agent`'s `env-validation.ts`) +asserts every declared key is present on the env before the factory +is invoked. A missing key raises `AgentEnvError` at agent-construction +time, which the loader's atomic-apply layer surfaces as a +`factory.construct.failed` deploy-apply error. + +The keys may name either: + +- **Capability registry keys** consumed via a `RuntimeCapabilities` + lookup (e.g. `mail.transport`, owned by `@intx/harness`'s + `createHarnessRuntimeCapabilities`). New capabilities are added to + `RuntimeCapabilityMap` in `@intx/types/runtime-capabilities`. +- **BaseEnv-extension fields** the host populates directly on the env + object (e.g. the `transport` and `address` fields `@intx/harness`'s + `MailEnv` adds for mail tools). + +Both shapes coexist by design — the runtime only checks that the key +is present on env; it does not distinguish how the host produces the +value. + +## Plugin factories + +Some tool packages contribute extra capabilities to other tool packages +(e.g. LSP's diagnostics middleware decorates posix's edit tools) +rather than producing a self-contained `ToolBundle`. These export +`AnnotatedPluginFactory` values, built via `definePlugin`. The loader +collects them and surfaces their results to tool factories via +`env.plugins` before any tool factory is invoked. + +Tool packages that consume plugins read `env.plugins` and filter by +shape — the agent runtime does not interpret the plugin's return +type. The plugin contract between producer and consumer (what shape +the plugin returns, what key names the consumer matches on) is +package-to-package, not part of the convention itself. + +**Host control over plugin chaining.** The agent runtime treats +plugin factories as opaque: it accepts whatever the host hands to +`createAgent` and surfaces the collected results to tool factories +via `env.plugins`. Hosts MAY invoke plugin factories one at a time +and re-feed each factory the prior results on `env.plugins`, +producing a chain in which each successive plugin sees its +predecessors. The default sidecar harness in this repository does +exactly that (`apps/sidecar/src/default-harness.ts`), so packages +authored against the in-tree sidecar can assume the chained shape. + +Package authors that intend to consume sibling plugin results must +inspect `env.plugins` at construction and fail loudly with a clear +message when a prerequisite is missing — the consumer cannot tell +whether the host is chaining or batching, and a silently-absent +plugin would degrade the consumer to half-built state. Likewise, +consumers must not assume any particular factory order beyond +"prerequisites appear before me when the host chains"; the contract +between producer and consumer is package-to-package, not part of +this convention. + +## Versioning + +Changes to the entry-module shape (what `interchange.tools` may +export) are breaking. A tool package's declared `interchange.tools` +module must remain compatible with the loader version it ships +against. The loader version is the `@intx/agent` major it depends on. diff --git a/vendor/intx/agent/README.md b/vendor/intx/agent/README.md new file mode 100644 index 000000000..b8420bb3e --- /dev/null +++ b/vendor/intx/agent/README.md @@ -0,0 +1,122 @@ +# @intx/agent + +In-process agent runtime built on `createReactorAssembly` from +`@intx/inference`. Construct an agent, `send()` it a message, +get a reply. + +Use this package when you want an agent you can drive from inside +your own program — a CLI, a worker, a test, an embedded assistant. +If you want an agent that lives behind a mailbox instead, see +`@intx/harness`. + +```ts +import { + createAgent, + createDefaultDirectorRegistry, + defineAgent, +} from "@intx/agent"; +import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; +import { createIsogitStore } from "@intx/storage-isogit/node"; + +// `apiKey` and `model` come from the caller's env / config; pick the +// shape that fits the deployment. The snippet below uses literals so +// it copy-pastes cleanly. +const apiKey = process.env.ANTHROPIC_API_KEY ?? ""; +const model = "claude-sonnet-5"; +const source = { + id: `anthropic:${model}`, + provider: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey, + model, +}; + +const workdir = "./tmp/my-agent"; +const storage = await createIsogitStore(workdir); + +const def = defineAgent({ + id: "my-agent", + systemPrompt: "...", + tools: [], + capabilities: [], + inference: { + sources: [{ provider: source.provider, model: source.model }], + }, +}); + +const agent = await createAgent(def, { + source, + storage, + workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), +}); + +const { reply } = await agent.send("hello"); +await agent.close(); +``` + +## Compactors + +A compactor rewrites the conversation history when the director asks for +it. The deployer registers compactors on `env.compactors` keyed by name; +the director picks a name at construction and emits +`caps.compact(name, reason)` when it wants the history rewritten. + +```ts +const agent = await createAgent(def, { + source, + storage, + workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + compactors: { "tail-only": tailOnlyCompactor() }, +}); +``` + +The director author sees the registered names through +`agentContext.compactorNames`, the same way `agentContext.toolDefinitions` +surfaces the resolved tools: + +```ts +import { defineDirector } from "@intx/agent"; +import { type } from "arktype"; + +defineDirector({ + id: "my-pkg/planner", + configSchema: type({}), + factory: (_config, _env, agent) => { + const compactor = agent.compactorNames[0]; + if (compactor === undefined) { + throw new Error("planner needs a compactor; none registered on env"); + } + return makePlanner({ compactor }); + }, +}); +``` + +The reactor resolves the name against `env.compactors` and runs the +compactor's `apply()` on the conversation turns. A `caps.compact(name, …)` +call against a name the deployer did not register produces a fatal +"no compactor registered" reactor error -- the director-author contract +is "pick from `compactorNames`," not "trust the deployer by convention." + +`env.compactors` is optional. A deployer that omits the field hands an +agent whose director never calls `caps.compact(...)` an environment that +matches an empty registry. + +## Where to start + +Read [`examples/agent-quickstart`](../../examples/agent-quickstart/README.md). +It is the minimum runnable program against this package. + +Then, depending on what you're trying to do: + +- Persistence and time travel — `agent-resume`, `agent-rewind`, `agent-audit-log` +- Tool I/O — `agent-blob-spill`, `agent-rich-tool`, `agent-structured-payload` +- Multi inference provider routing — `agent-multi-provider` +- End-to-end with real tools — `coding-agent` + +See [`examples/README.md`](../../examples/README.md) for the full index. diff --git a/vendor/intx/agent/VENDORED-FROM b/vendor/intx/agent/VENDORED-FROM new file mode 100644 index 000000000..793972d10 --- /dev/null +++ b/vendor/intx/agent/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/agent) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. diff --git a/vendor/intx/agent/package.json b/vendor/intx/agent/package.json new file mode 100644 index 000000000..867f15717 --- /dev/null +++ b/vendor/intx/agent/package.json @@ -0,0 +1,40 @@ +{ + "name": "@intx/agent", + "description": "In-process agent runtime: construct an agent, send it a message, get a reply", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./testing": { + "types": "./src/testing/index.ts", + "default": "./src/testing/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/inference": "workspace:*", + "@intx/log": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/agent/src/agent.ts b/vendor/intx/agent/src/agent.ts new file mode 100644 index 000000000..03b8bbe69 --- /dev/null +++ b/vendor/intx/agent/src/agent.ts @@ -0,0 +1,899 @@ +// In-process agent runtime. +// +// `createAgent(def, env)` is the single entry point. The `def` is the +// portable, hashable `AgentDefinition` (id, system prompt, tool +// factories, director ref, inference preferences, capabilities, tags). +// The `env` is the runtime environment supplying the active inference +// source, the context store, the working directory, the audit sink, +// the authorize callback, and the director registry. The agent +// instantiates against those: it locks the context directory, walks +// each tool factory to build its tool runner, resolves the director +// against the registry, and wires the result into the reactor +// assembly. The reactor is wrapped exactly once. +// +// Composition: +// - `send()` enqueues into a FIFO `SendQueue` capped at +// `env.sendQueueMax`. Per-send `AbortSignal` removes queued items or +// rejects in-flight callers while letting the reactor cycle finish +// in the background. +// - `stream()` returns a bounded `StreamConsumer` iterator; consumers +// buffer independently and noisy backpressure poisons only the +// affected iterator. +// - `close()` aborts the reactor, drains the send queue with +// `AgentClosedError`, terminates every active stream iterator, waits +// up to `env.closeTimeoutMs` for the reactor's shutdown sequence to +// complete (audit flush, in-flight commits), and finally releases +// the singleton-per-`workdir` lock so another agent can open the +// same directory. +// +// `setSource` covers the whole source: id/provider/baseURL/apiKey/model +// plus the model-bound `defaults` and `capabilities`. Credentials and +// model rotate together via the shared source object the reactor reads +// lazily at the start of each inference call. The director never names +// a model -- `capabilities.infer(options?)` does not take one -- so the +// active source's model is the single source of truth and rotations +// take effect on the next inference call without any wrapper. +// +// Tool factories are bundle-shaped: each declares `(env) => ToolBundle` +// via `defineTool`. The agent invokes each factory once at construction, +// collects the bundles' definitions, and dispatches calls to the +// owning bundle's `run`. Bundle lifetimes (and any `dispose` step) are +// the caller's responsibility -- the env is the agent's dependency +// contract; the caller owns the lifetime of what it puts in env. + +import { + createReactorAssembly, + type Dependencies, + type ReactorEmittedEvent, +} from "@intx/inference"; +import { createDefaultDependencies } from "@intx/inference/providers"; +import { getLogger } from "@intx/log"; +import { createInboundMessage } from "@intx/mime"; +import type { ErrorRecord } from "@intx/types/audit"; +import type { + ApprovalSnapshot, + AssistantTurn, + BlobReader, + ContextCommit, + ContextStore, + ConversationTurn, + InboundMessage, + InferenceSource, + ReactorDirector, + ToolCall, + ToolDefinition, + ToolResult, + ToolRunner, +} from "@intx/types/runtime"; + +import type { AgentDefinition } from "./definition"; +import { validateDirectorConfig } from "./director"; +import type { DirectorRef } from "./director-types"; +import type { BaseEnv } from "./env"; +import { validateEnv } from "./env-validation"; +import { acquireContextDirLock, type ContextDirLock } from "./lock"; +import { createSourceRegistry } from "./source"; +import { createSendQueue, type SendQueue } from "./send-queue"; +import { createStreamConsumer, type StreamConsumer } from "./stream"; +import { DuplicateToolError, type ToolBundle } from "./tool"; + +const logger = getLogger(["interchange", "agent"]); + +// Synthetic recipient/sender used when `agent.send(content)` is +// called with a plain string. `agent.send` is the in-process API for +// driving an agent without a transport; the synthesized message is +// never sent over the wire, so the addresses are just shape-fillers +// for the reactor's MIME-derived event shape. The `from` field is +// override-able via `SendOptions.from` because callers occasionally +// want to stamp a meaningful sender for audit purposes. The `to` +// field is fixed because no in-tree call path makes a routing or +// audit decision on it: harness-wrapped agents do not surface +// `agent.send` (the `Harness` shape exposes only deliver/setSource/ +// stream/close/blobReader), and standalone agents have no addressing +// substrate to begin with. Callers that need an addressable inbound +// message build the `InboundMessage` themselves and pass it to +// `agent.send(message)` directly, bypassing this synthesis path. +const DEFAULT_SEND_FROM = "user@local"; +const DEFAULT_SEND_TO = "agent@local"; +const DEFAULT_SEND_QUEUE_MAX = 16; +const DEFAULT_STREAM_BUFFER_MAX = 1024; +const DEFAULT_CLOSE_TIMEOUT_MS = 5000; + +export type SendOptions = { + /** + * Abort signal for this send. When the signal fires before processing + * the call is dropped from the queue and the promise rejects with the + * signal's reason. When it fires mid-cycle the promise rejects + * immediately, but the underlying reactor cycle keeps running because + * the reactor does not expose per-cycle cancellation -- the next + * queued send waits for that cycle to finish before starting. The + * reply (if any) is still visible via `stream()` and `history()`. + */ + signal?: AbortSignal; + /** Override the default "from" header on the synthetic inbound message. */ + from?: string; +}; + +export type SendResult = + | { + type: "reply"; + /** Reply text emitted by the director's `reply` action. */ + reply: string; + /** + * Full-fidelity assistant turn that produced the reply. Captured from + * the reactor's `inference.done` event preceding `connector.reply`. + */ + turn: ConversationTurn; + } + | { + /** + * The reactor parked on a gate before producing a reply. The cycle + * is not finished -- it will resume when the correlated external + * decision is delivered. `correlationId` identifies the pending + * operation the caller resumes against. + */ + type: "suspended"; + correlationId: string; + /** + * Approver-facing snapshot of the parked tool call, when the reactor + * carried one on the gate-blocked event. Forwarded so the runtime can + * register it with the suspension. Absent for suspensions with no + * snapshot (an authz extension wired with no tool definitions). + */ + approvalSnapshot?: ApprovalSnapshot; + }; + +/** + * A `reactor.gate.blocked` event settled the active send but carried no + * `correlationId`, so the resulting suspension has no handle a caller + * could resume against. The reactor omits `correlationId` for gates + * parked without a correlation (e.g. a director suspend with no + * correlated external decision), and `send()` cannot hand back an + * unresumable outcome -- it surfaces this instead. + */ +export class GateSuspendedWithoutCorrelationError extends Error { + readonly gateId: string; + + constructor(gateId: string) { + super( + `reactor suspended on gate ${gateId} without a correlationId; the send has no handle to resume against`, + ); + this.name = "GateSuspendedWithoutCorrelationError"; + this.gateId = gateId; + } +} + +export type Agent = { + send( + content: string | InboundMessage, + opts?: SendOptions, + ): Promise; + stream(): AsyncIterable; + deliver(message: InboundMessage): void; + /** + * Begin shutdown. The reactor is aborted, the send queue drains with + * `AgentClosedError`, every active stream iterator terminates, the + * shutdown sequence (audit flush + in-flight commits) is awaited up + * to `env.closeTimeoutMs`, and the singleton-per-workdir lock is + * released so another agent can open the same directory. + * + * Stream consumers are terminated synchronously before + * `shutdownComplete` is awaited, so any reactor event emitted in the + * shutdown window (after `reactor.abort()` but before the assembly's + * `onShutdown` resolves) is no longer visible to a `stream()` + * iterator. The audit path is not affected: `inference.error` and + * `reactor.error` records emitted in that window still flow through + * `accumulatedErrors` and are flushed by `onShutdown`. Callers that + * need to observe late events should subscribe before `close()` and + * tolerate the iterator's terminal close. + */ + close(): Promise; + /** + * Replace the active source's fields in place. Picked up at the start + * of the next inference call. `model` rotates alongside the + * credentials -- the director does not name a model, so the active + * source's `model` is what the next inference call uses without any + * additional plumbing. + */ + setSource(source: InferenceSource): void; + /** + * Replace the whole ordered source list and activate `defaultSource`. + * Used when the control plane pushes a re-resolved source list to a + * running agent (credential rotation, sidecar reconnect). + */ + setSources(sources: InferenceSource[], defaultSource: string): void; + /** + * Project conversation history from the underlying context store. + * Remains callable after `close()` -- reads do not need the reactor + * and the store is not destroyed by close. Returns the full-fidelity + * `ConversationTurn[]` from the store's latest committed state. + */ + history(): Promise; + /** + * List recent checkpoints from the context store. Remains callable + * after `close()` for the same reason as `history()`. + */ + checkpoints(limit?: number): Promise; + /** + * Read the conversation turns recorded at a specific commit hash. + * Remains callable after `close()` for the same reason as + * `history()`. + */ + readAt(hash: string): Promise; + readonly blobReader: BlobReader; +}; + +export class AgentClosedError extends Error { + constructor() { + super("agent is closed"); + this.name = "AgentClosedError"; + } +} + +interface ResolvedTools { + readonly definitions: readonly ToolDefinition[]; + readonly runner: ToolRunner & { + readonly definitions: readonly ToolDefinition[]; + }; + /** + * The bundles `resolveTools` constructed in order. Returned so the + * surrounding `createAgent` can dispose them on construction + * failures past `resolveTools` (resolveDirector, createReactorAssembly, + * createSourceRegistry, etc.) -- the caller never reaches the + * returned agent in those paths, so the per-`ToolBundle` "caller + * owns lifetime" contract has no caller to honor it. Once + * `createAgent` returns successfully, the bundle list is dropped + * and disposal returns to being the caller's responsibility per the + * standard `ToolBundle` contract. + */ + readonly bundles: readonly ToolBundle[]; +} + +/** + * Walk each annotated tool factory, build the bundle, and produce a + * single `ToolRunner` that dispatches calls by tool name to the + * originating bundle. Throws on duplicate tool names across bundles. + * + * Bundle lifetimes (disposal) are the caller's responsibility per the + * `ToolBundle` contract once `createAgent` returns. While + * `createAgent` is still constructing -- whether the failure surfaces + * inside this function or later in `createAgent`'s body -- there is + * no caller to honor that contract, so the bundles list is exposed + * for the surrounding `try`/`finally` to dispose on failure. + */ +function resolveTools( + def: AgentDefinition, + env: EnvReq, +): ResolvedTools { + const byName = new Map(); + const definitions: ToolDefinition[] = []; + // Track constructed bundles so we can dispose them on a later + // factory's failure. Once `resolveTools` returns successfully the + // caller (createAgent) is the lifetime owner per the `ToolBundle` + // contract; until then the only reference is in this function. + const constructed: ToolBundle[] = []; + + try { + for (const factory of def.toolFactories) { + const bundle = factory(env); + constructed.push(bundle); + for (const definition of bundle.definitions) { + if (byName.has(definition.name)) { + throw new DuplicateToolError(definition.name); + } + byName.set(definition.name, bundle); + definitions.push(definition); + } + } + } catch (cause) { + // Dispose every bundle we did successfully construct before + // re-raising. Without this, factories that allocate resources at + // construction time (mail bundles holding an IMAP session, posix + // bundles spawning an LSP server, etc.) leak when a later + // factory throws or a duplicate-name collision aborts the walk. + for (const bundle of constructed) { + if (bundle.dispose === undefined) continue; + try { + // Swallow disposer errors so the original construction + // failure remains the one the caller sees; a noisy disposer + // running during rollback would mask the real problem. + // + // `void bundle.dispose()` would not be enough on its own: it + // discards the returned promise but leaves any rejection in + // flight, which the surrounding synchronous try/catch cannot + // observe and the runtime surfaces as an unhandled promise + // rejection. We attach a no-op `.catch` to absorb async + // rejections and let the throw below propagate immediately + // (the caller's lock is still held; awaiting rollback would + // delay the construction failure for no benefit). + const result = bundle.dispose(); + if (result instanceof Promise) { + result.catch(() => { + // Swallow per the comment above. + }); + } + } catch { + // Synchronous throws from a non-async dispose that throws + // before returning a promise. Same intent as the async path: + // never let rollback noise mask the original failure. + } + } + throw cause; + } + + const runner: ToolRunner & { definitions: readonly ToolDefinition[] } = { + definitions: Object.freeze([...definitions]), + async run(call: ToolCall, signal: AbortSignal): Promise { + const bundle = byName.get(call.name); + if (bundle === undefined) { + return { + callId: call.id, + content: `unknown tool: ${call.name}`, + isError: true, + }; + } + try { + return await bundle.run(call, signal); + } catch (err) { + return { + callId: call.id, + content: err instanceof Error ? err.message : String(err), + isError: true, + }; + } + }, + }; + + return { definitions, runner, bundles: constructed }; +} + +function resolveDirector( + def: AgentDefinition, + env: EnvReq, + toolDefinitions: readonly ToolDefinition[], + compactorNames: readonly string[], +): ReactorDirector { + const ref: DirectorRef = def.director ?? env.directors.buildDefaultRef(); + const factory = env.directors.resolve(ref); + // Re-validate ref.config against the factory's registered schema. + // `defineDirector.build(config)` validates at construction time, but + // `DirectorRef` is a public structural type -- nothing forces refs + // through `build`. A hand-constructed ref would otherwise reach the + // factory body with whatever shape the author wrote. + validateDirectorConfig(ref.config, factory.configSchema); + return factory(ref.config, env, { + systemPrompt: def.systemPrompt, + toolDefinitions, + compactorNames, + }); +} + +export async function createAgent( + def: AgentDefinition, + env: EnvReq, +): Promise { + validateEnv(def, env); + + const lock: ContextDirLock = acquireContextDirLock(env.workdir); + + // The construction below acquires several resources before the + // returned Agent's `close()` becomes reachable. Anything that + // throws between here and the final return leaks the lock and any + // tool-bundle resources unless we explicitly release them. Track + // the success path with a flag, release the lock in `finally` when + // we never reached the return, and dispose every successfully + // constructed tool bundle so post-`resolveTools` failures + // (resolveDirector throw, createReactorAssembly throw, + // createSourceRegistry throw, reactor.start throw) don't leak the + // bundles `resolveTools` built. The intra-`resolveTools` rollback + // disposes bundles that were constructed before the throwing + // factory; this outer rollback covers the rest. + let succeeded = false; + let bundlesForRollback: readonly ToolBundle[] = []; + try { + const resolvedTools = resolveTools(def, env); + bundlesForRollback = resolvedTools.bundles; + const sourceRegistry = createSourceRegistry({ + sources: env.sources, + defaultSource: env.defaultSource, + }); + // Capture the registered names as a frozen snapshot at construction + // so the director receives a stable list it can iterate. The + // reactor assembly retains the live `env.compactors` reference for + // `caps.compact` lookups, so a deployer that mutates the registry + // after `createAgent` returns would diverge this snapshot from the + // reactor's resolution. Treat `env.compactors` as immutable + // post-construction. + const compactorNames: readonly string[] = Object.freeze( + Object.keys(env.compactors ?? {}), + ); + const director = resolveDirector( + def, + env, + resolvedTools.definitions, + compactorNames, + ); + + const contextStore: ContextStore = env.storage; + const auditStore = env.audit; + const authorize = env.authorize; + const deps: Dependencies = env.deps ?? createDefaultDependencies(); + + const sessionId = env.sessionId ?? crypto.randomUUID(); + const streamBufferMax = env.streamBufferMax ?? DEFAULT_STREAM_BUFFER_MAX; + const streamConsumers = new Set(); + + // Pre-start buffer for events emitted between `reactor.start()` and + // the first `stream()` consumer attaching. Without this buffer + // those events fan out into an empty consumer set and are dropped + // silently: `reactor.start()` runs synchronously inside + // `createAgent`, before the caller has a chance to register a + // consumer, so a `reactor.start` event (or any other event the + // reactor emits during its synchronous startup window) would be + // lost. We buffer up to `streamBufferMax` events; when the first + // consumer attaches, the buffer is drained into it and discarded. + // Subsequent consumers see only events emitted after their own + // registration, matching the existing per-consumer fan-out + // semantics. Overflow during the pre-start window drops the + // oldest events with a log warning rather than throwing: aborting + // `reactor.start()` mid-startup leaves the agent in a worse state + // than missing observability for the very earliest events, and a + // startup that emits more than `streamBufferMax` events before + // any consumer registers is a pathology the caller can observe + // via the warning. + let preStartBuffer: ReactorEmittedEvent[] | undefined = []; + let preStartBufferOverflows = 0; + + // Per-active-cycle bookkeeping for send(). The reactor produces one or + // more inference.done events during a cycle; we keep the most recent + // assistant turn so the final connector.reply can be paired with the + // full-fidelity turn (rather than a synthesized text-only fallback). + type ActiveCycle = { lastAssistantTurn: AssistantTurn | undefined }; + let activeCycle: ActiveCycle | null = null; + + // sendQueue is built after the reactor (since its `start` callback + // delivers into the reactor), but handleEvent -- which is wired + // into the reactor's assembly -- needs to see sendQueue. Assigned + // exactly once after the reactor exists and before + // reactor.start(); no event can reach handleEvent before the + // queue is wired. + // + // The cycle is irreducible at the type level: `handleEvent` + // reads `sendQueue` from closure; `sendQueue.start` calls + // `reactor.deliver`; `reactor` is constructed with + // `onEvent: handleEvent`. Three references, each pointing at + // the next. `const` requires its initializer at declaration time, + // which forces the cycle to break at one of these edges -- + // every break either threads an extra parameter through + // handleEvent (which the reactor's `onEvent` shape does not + // accept), wraps sendQueue behind a `{ value: SendQueue }` cell + // (which makes every send-site check for undefined that the + // construction order already guarantees absent), or splits + // handleEvent into a factory that takes sendQueue as input + // (which moves the same forward-declaration problem one level + // up). The `let` here is the smallest expression of the cycle + // the language allows; the comment block above is what makes + // the "assigned before any reachable read" invariant explicit. + // eslint-disable-next-line prefer-const -- forward declaration; const cannot express this ordering + let sendQueue: SendQueue; + + // shutdownComplete resolves from the assembly's onShutdown hook + // (composed after audit flush by the assembly) or, as a fallback, from + // handleEvent observing the reactor's terminal `reactor.done` event. + // close() awaits this (with a timeout) before releasing the + // workdir lock so a subsequent createAgent on the same directory + // sees a quiesced store. + // + // Use Promise.withResolvers so `resolveShutdown` is bound to the + // promise's resolve function at the point of declaration rather + // than after the Promise constructor's synchronous executor runs; + // the previous pattern needed a no-op seed for a TDZ window that + // the language already closes synchronously. + const { + promise: shutdownComplete, + resolve: resolveShutdown, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers() is the conventional shape for a fire-and-forget settled-signal; matches Promise used elsewhere on this assembly + } = Promise.withResolvers(); + + // Error accumulation. inference.error and reactor.error events + // observed at the assembly's onEvent boundary accumulate here and + // flush at the assembly's afterCheckpoint and onShutdown lifecycle + // hooks. Audit recording is always wired now: env.audit is required. + // + // Serialization through `flushInProgress` + `pendingFollowUp`: if + // a flush is already running, all concurrent callers ride a single + // shared follow-up promise that fires exactly once after the + // current flush settles. This prevents the multi-caller race + // where N concurrent chained continuations each observe + // `flushInProgress === undefined` in the same microtask drain and + // start parallel `commitErrors(batch)` invocations on the same + // prefix -- which would double-commit and incorrectly splice the + // accumulator. The shared follow-up clears itself before invoking + // the next flush, so a fourth caller arriving after the follow-up + // begins still observes a clean state and starts its own flush. + const accumulatedErrors: ErrorRecord[] = []; + let errorSeq = 0; + let flushInProgress: Promise | undefined; + let pendingFollowUp: Promise | undefined; + + function flushErrors(): Promise { + if (flushInProgress !== undefined) { + // If another caller already arranged a follow-up flush after + // the current one settles, ride that. Otherwise arrange one + // and let every later concurrent caller share it. Run the + // follow-up on both fulfilment and rejection: if the in-flight + // flush failed, the accumulator still holds its records and + // the next attempt should retry rather than observe the prior + // failure. + if (pendingFollowUp !== undefined) return pendingFollowUp; + pendingFollowUp = flushInProgress.then( + () => { + pendingFollowUp = undefined; + return flushErrors(); + }, + () => { + pendingFollowUp = undefined; + return flushErrors(); + }, + ); + return pendingFollowUp; + } + if (accumulatedErrors.length === 0) return Promise.resolve(); + const count = accumulatedErrors.length; + const batch = accumulatedErrors.slice(0, count); + // Splice only after a successful commit. A throwing audit store + // must not lose the batch -- the next flush hook (a later + // afterCheckpoint or the onShutdown drain) retries the same + // records. Note this means that on a permanent audit-store + // failure, the accumulator grows unbounded; the assembly's + // expectation is that commitErrors failures are transient. + flushInProgress = (async () => { + try { + await auditStore.commitErrors(batch); + accumulatedErrors.splice(0, count); + } finally { + flushInProgress = undefined; + } + })(); + return flushInProgress; + } + + function buildSyntheticTurn(text: string): ConversationTurn { + return { + role: "assistant", + content: [{ type: "text", text }], + model: sourceRegistry.active.model, + timestamp: Date.now(), + }; + } + + function handleEvent(event: ReactorEmittedEvent): void { + if (event.type === "inference.error") { + accumulatedErrors.push({ + source: "inference", + category: event.data.error.category, + message: event.data.error.message, + fatal: false, + timestamp: new Date().toISOString(), + sessionId, + seq: errorSeq++, + ...(event.data.error.statusCode !== undefined + ? { statusCode: event.data.error.statusCode } + : {}), + }); + } else if (event.type === "reactor.error") { + accumulatedErrors.push({ + source: "reactor", + category: "reactor_error", + message: event.data.error, + fatal: event.data.fatal, + timestamp: new Date().toISOString(), + sessionId, + seq: errorSeq++, + }); + } + + if (activeCycle !== null && event.type === "inference.done") { + activeCycle.lastAssistantTurn = event.data.turn; + } + + if (activeCycle !== null) { + if (event.type === "connector.reply") { + const turn: ConversationTurn = + activeCycle.lastAssistantTurn ?? + buildSyntheticTurn(event.data.content); + activeCycle = null; + sendQueue.resolveActive({ + type: "reply", + reply: event.data.content, + turn, + }); + } else if (event.type === "reactor.gate.blocked") { + // The reactor parked on a gate before producing a reply. This + // is a terminal outcome for the active send: the cycle will not + // continue until the correlated external decision is delivered, + // and a parked cycle does not emit connector.reply or + // reactor.done, so leaving the send unsettled would hang the + // caller. Resolve with the suspended outcome so the caller can + // resume against the correlationId. A gate parked without a + // correlationId is unresumable -- surface it rather than hand + // back an outcome with no handle. + const { correlationId, approvalSnapshot } = event.data; + activeCycle = null; + if (correlationId === undefined) { + sendQueue.rejectActive( + new GateSuspendedWithoutCorrelationError(event.data.gateId), + ); + } else { + sendQueue.resolveActive({ + type: "suspended", + correlationId, + ...(approvalSnapshot !== undefined ? { approvalSnapshot } : {}), + }); + } + } else if (event.type === "reactor.error" && event.data.fatal) { + // Only fatal reactor errors terminate the active send. Non-fatal + // errors (e.g. transient write/commit failures the reactor is + // recovering from) are surfaced via stream() but must not + // resolve send() -- the cycle is still running and may yet + // produce connector.reply or a fatal error. + activeCycle = null; + sendQueue.rejectActive( + new Error(`reactor error: ${event.data.error}`), + ); + } else if (event.type === "reactor.done") { + activeCycle = null; + sendQueue.rejectActive(new AgentClosedError()); + } + } + + // reactor.done is the reactor's terminal event. Resolve + // shutdownComplete here in addition to the onShutdown hook so close() + // does not hang for the full closeTimeoutMs on paths where the hook + // never fires (e.g. the reactor's context-store load fails during + // start, or the composed onShutdown wrapper throws during audit + // flush). resolveShutdown is idempotent. + if (event.type === "reactor.done") { + resolveShutdown(); + } + + // Pre-start window: if no consumer has attached yet, buffer the + // event so the first consumer to attach picks it up. The buffer + // is discarded after the first drain; later consumers see only + // events emitted after their own registration. Overflow drops + // the oldest event with a log warning -- raising here would + // abort reactor startup, which is worse than missing + // observability for the earliest events. + if (preStartBuffer !== undefined && streamConsumers.size === 0) { + if (preStartBuffer.length >= streamBufferMax) { + preStartBuffer.shift(); + preStartBufferOverflows += 1; + } + preStartBuffer.push(event); + return; + } + + // Iterate a snapshot so removing closed consumers mid-iteration is + // not just relying on Set's iteration tolerance. + for (const consumer of Array.from(streamConsumers)) { + consumer.push(event); + if (consumer.closed) { + streamConsumers.delete(consumer); + } + } + } + + const { reactor, blobReader } = createReactorAssembly({ + sessionId, + director, + source: sourceRegistry.active, + failOverToNextSource: () => sourceRegistry.failOverToNextSource(), + resetToPreferredSource: () => sourceRegistry.resetToPreferredSource(), + toolRunner: resolvedTools.runner, + contextStore, + onEvent: handleEvent, + auditStore, + authorize, + toolDefinitions: resolvedTools.definitions, + onShutdown: async () => { + try { + await flushErrors(); + } finally { + resolveShutdown(); + } + }, + afterCheckpoint: flushErrors, + ...(env.sizeCapMaxChars !== undefined + ? { sizeCapMaxChars: env.sizeCapMaxChars } + : {}), + ...(env.doomLoopThreshold !== undefined + ? { doomLoopThreshold: env.doomLoopThreshold } + : {}), + deps, + ...(env.compactors !== undefined ? { compactors: env.compactors } : {}), + }); + + sendQueue = createSendQueue({ + maxDepth: env.sendQueueMax ?? DEFAULT_SEND_QUEUE_MAX, + start: (message) => { + activeCycle = { lastAssistantTurn: undefined }; + reactor.deliver(message); + }, + }); + + reactor.start(); + + let closed = false; + + function ensureOpen(): void { + if (closed) throw new AgentClosedError(); + } + + function buildInboundMessage( + content: string | InboundMessage, + opts?: SendOptions, + ): InboundMessage { + if (typeof content !== "string") return content; + // Conversation messages use `content` (a string); the mail-builder + // rejects passing `payload` for conversation types. + return createInboundMessage({ + from: opts?.from ?? DEFAULT_SEND_FROM, + to: DEFAULT_SEND_TO, + content, + interchangeType: "conversation.message", + }); + } + + function send( + content: string | InboundMessage, + opts?: SendOptions, + ): Promise { + // Closed-agent errors come back as rejections so callers can handle + // them with `.catch()` instead of having to defensively wrap every + // `agent.send(...)` in a try/catch. `SendQueueFullError` from + // `sendQueue.enqueue` is left as a synchronous throw -- it signals a + // programmer error (the caller exceeded the configured queue cap) + // and per the design must fail loud. + if (closed) return Promise.reject(new AgentClosedError()); + const message = buildInboundMessage(content, opts); + return sendQueue.enqueue(message, opts?.signal); + } + + function stream(): AsyncIterable { + ensureOpen(); + const consumer = createStreamConsumer(streamBufferMax); + // Drain the pre-start buffer into the first consumer that + // attaches so events emitted between reactor.start() and the + // first stream() call are not lost. The buffer is discarded + // after the first drain -- later consumers see only events + // emitted after their own registration, matching the per- + // consumer semantics every other code path expects. + if (preStartBuffer !== undefined) { + if (preStartBufferOverflows > 0) { + logger.warn`pre-start event buffer overflowed by ${preStartBufferOverflows} event(s) before the first stream() consumer attached; oldest events were dropped`; + } + for (const event of preStartBuffer) consumer.push(event); + preStartBuffer = undefined; + } + streamConsumers.add(consumer); + return consumer.iterator(); + } + + function deliver(message: InboundMessage): void { + ensureOpen(); + reactor.deliver(message); + } + + function setSource(source: InferenceSource): void { + ensureOpen(); + sourceRegistry.setSource(source); + } + + function setSources( + sources: InferenceSource[], + defaultSource: string, + ): void { + ensureOpen(); + sourceRegistry.setSources(sources, defaultSource); + } + + async function history(): Promise { + const loaded = await contextStore.load(); + return loaded.turns; + } + + async function checkpoints(limit?: number): Promise { + return contextStore.log(limit); + } + + async function readAt(hash: string): Promise { + return contextStore.readAt(hash); + } + + async function close(): Promise { + if (closed) return; + closed = true; + reactor.abort("user_disconnect"); + sendQueue.drain(new AgentClosedError()); + activeCycle = null; + for (const consumer of streamConsumers) consumer.close(); + streamConsumers.clear(); + + // Surface any pre-start buffer state the caller never observed. + // The buffer drains into the first `stream()` consumer at + // attachment time and logs its overflow count then. If no + // consumer ever attached (e.g. a `send()`-only caller that + // never subscribed to the event stream), the buffer and its + // overflow counter would silently disappear here without an + // operator signal. Log the overflow once at close time so a + // startup pathology that dropped reactor.start-window events + // is at least observable in the logs. + if (preStartBuffer !== undefined && preStartBufferOverflows > 0) { + logger.warn`pre-start event buffer overflowed by ${preStartBufferOverflows} event(s) and no stream() consumer ever attached to drain it; oldest events were dropped`; + } + preStartBuffer = undefined; + + // Wait for the reactor's shutdown sequence (audit flush, in-flight + // commits) before releasing the lock so a subsequent createAgent on + // the same workdir does not race with background writers against + // the same .git directory. The timeout is a backstop: if the + // reactor's shutdown is genuinely stuck (e.g. a parked test fetch + // that never resolves) we release the lock anyway rather than + // deadlock the caller. `closeTimeoutMs: 0` disables the wait. + const timeoutMs = env.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; + if (timeoutMs > 0) { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }); + try { + await Promise.race([shutdownComplete, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } + lock.release(); + } + + const agent: Agent = { + send, + stream, + deliver, + close, + setSource, + setSources, + history, + checkpoints, + readAt, + blobReader, + }; + succeeded = true; + return agent; + } finally { + if (!succeeded) { + // Dispose every successfully constructed bundle. Mirror the + // intra-`resolveTools` rollback shape: swallow async rejections + // via a `.catch` (a bare `void promise.dispose()` would leave + // the rejection in flight and surface as an unhandled rejection + // on the event loop), swallow synchronous throws with the + // surrounding try/catch, and let the throw the caller actually + // raised propagate immediately rather than awaiting cleanup. + for (const bundle of bundlesForRollback) { + if (bundle.dispose === undefined) continue; + try { + const result = bundle.dispose(); + if (result instanceof Promise) { + result.catch(() => { + // Swallow per the comment above. + }); + } + } catch { + // Synchronous throws from a non-async dispose that throws + // before returning a promise. Same intent as the async + // path: never let rollback noise mask the original failure. + } + } + lock.release(); + } + } +} diff --git a/vendor/intx/agent/src/canonicalize.ts b/vendor/intx/agent/src/canonicalize.ts new file mode 100644 index 000000000..a666aea48 --- /dev/null +++ b/vendor/intx/agent/src/canonicalize.ts @@ -0,0 +1,208 @@ +// Deterministic JSON serialization for deploy-hash inputs. +// +// `canonicalizeForHash` produces stable bytes from a value tree that +// participates in the deploy hash (notably `DirectorRef.config` and the +// `AgentDefinition` envelope). The output is the encoded form of a +// canonical JSON document: object keys NFC-normalized then sorted, +// strings normalized to NFC, no whitespace. Non-JSON values (Date, Map, +// Set, function, undefined, symbol, NaN, +/-Infinity) are rejected so +// the hash cannot silently absorb a value the JSON receiver could not +// reproduce. +// +// The implementation builds a normalized JS value tree first, then +// `JSON.stringify`s it. The intermediate tree lets the cycle check and +// type rejections share a single recursive walk. +// +// Key-ordering caveat. The walk sorts NFC-normalized keys +// lexicographically before assigning them into the intermediate plain +// object. `JSON.stringify` then walks the object's own keys in the +// engine's iteration order, which per ECMA-262 +// (OrdinaryOwnPropertyKeys) lists integer-indexed string keys first in +// ascending numeric order, then the remaining keys in insertion order. +// For purely string-keyed maps the emitted bytes follow the algorithm's +// lex sort; for integer-keyed maps (string keys like "1", "2", "10") +// the engine re-orders the integer prefix numerically, so the emitted +// bytes do not match a strict lex sort of the same keys +// ("1","10","2"). The behavior is deterministic across every JS engine +// that implements OrdinaryOwnPropertyKeys (i.e. every engine since +// ES2020), so deploy-hash equality across producers is preserved. A +// future engine that changed this rule would change the canonical +// bytes; if that becomes a concern, replace `JSON.stringify` with a +// hand-rolled emitter that walks the sorted-key list directly. + +type JsonLike = + | null + | boolean + | number + | string + | readonly JsonLike[] + | { readonly [key: string]: JsonLike }; + +const encoder = new TextEncoder(); + +export class CanonicalizationError extends Error { + readonly path: readonly string[]; + + constructor(message: string, path: readonly string[]) { + super( + path.length === 0 + ? message + : `${message} (at ${path.length === 1 ? path[0] : path.join(".")})`, + ); + this.name = "CanonicalizationError"; + this.path = path; + } +} + +function normalize( + value: unknown, + path: readonly string[], + seen: WeakSet, +): JsonLike { + if (value === null) return null; + + if (typeof value === "boolean") return value; + + if (typeof value === "string") { + return value.normalize("NFC"); + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new CanonicalizationError( + `non-finite number (${String(value)}) is not valid JSON`, + path, + ); + } + return value; + } + + if (typeof value === "undefined") { + throw new CanonicalizationError("undefined is not valid JSON", path); + } + + if (typeof value === "symbol") { + throw new CanonicalizationError("symbol is not valid JSON", path); + } + + if (typeof value === "function") { + throw new CanonicalizationError("function is not valid JSON", path); + } + + if (typeof value === "bigint") { + throw new CanonicalizationError("bigint is not valid JSON", path); + } + + // Objects: arrays, plain records, or rejected built-ins. After the + // primitive checks above, the only remaining narrowed type is + // `object`. + const obj: object = value; + + if (seen.has(obj)) { + throw new CanonicalizationError("cycle detected", path); + } + seen.add(obj); + + try { + if (Array.isArray(obj)) { + const out: JsonLike[] = []; + for (let i = 0; i < obj.length; i++) { + out.push(normalize(obj[i], [...path, `[${String(i)}]`], seen)); + } + return out; + } + + if ( + obj instanceof Date || + obj instanceof Map || + obj instanceof Set || + obj instanceof RegExp || + obj instanceof Promise || + obj instanceof Error || + obj instanceof ArrayBuffer || + ArrayBuffer.isView(obj) + ) { + throw new CanonicalizationError( + `${obj.constructor.name} is not valid JSON`, + path, + ); + } + + const proto: unknown = Object.getPrototypeOf(obj); + if (proto !== Object.prototype && proto !== null) { + let protoName = "unknown"; + if ( + typeof proto === "object" && + proto !== null && + "constructor" in proto && + typeof proto.constructor === "function" + ) { + protoName = proto.constructor.name; + } + throw new CanonicalizationError( + `non-plain object (prototype ${protoName}) is not valid JSON`, + path, + ); + } + + // After the proto check, `obj` is a plain Record. + // Index it through a generic record type to drop symbol keys (which + // Object.keys also drops). + const record: Record = Object.fromEntries( + Object.entries(obj), + ); + // Normalize keys to NFC before sorting and before indexing the + // output. Two failure modes ride on this ordering: (a) if two + // distinct raw keys normalize to the same NFC form, silently + // overwriting one with the other would drop data and the deploy + // hash would no longer be a faithful function of the input; (b) + // sorting raw keys and then normalizing produces an output key + // order that is not the canonical NFC-sorted order, so two + // producers (one pre-normalizing, one not) would hash the same + // logical value to different bytes. Normalize first, raise on any + // NFC collision, then sort. + const byNFC = new Map(); + for (const rawKey of Object.keys(record)) { + const nfcKey = rawKey.normalize("NFC"); + const existing = byNFC.get(nfcKey); + if (existing !== undefined && existing !== rawKey) { + throw new CanonicalizationError( + `keys ${JSON.stringify(existing)} and ${JSON.stringify(rawKey)} ` + + `NFC-normalize to the same value (${JSON.stringify(nfcKey)})`, + path, + ); + } + byNFC.set(nfcKey, rawKey); + } + const nfcKeys = [...byNFC.keys()].sort((a, b) => + a < b ? -1 : a > b ? 1 : 0, + ); + const out: Record = {}; + for (const nfcKey of nfcKeys) { + const rawKey = byNFC.get(nfcKey); + if (rawKey === undefined) continue; + out[nfcKey] = normalize(record[rawKey], [...path, rawKey], seen); + } + return out; + } finally { + seen.delete(obj); + } +} + +/** + * Produce stable bytes for a value tree. The output is the UTF-8 + * encoded form of a canonical JSON document with sorted object keys, + * NFC-normalized strings, and no whitespace. Throws + * `CanonicalizationError` on any non-JSON value or cycle. + * + * Equality of two outputs implies equality of the canonical structural + * form of the inputs; consumers may safely hash the output to compare + * value identity across local-dev and production bundles. + */ +export function canonicalizeForHash(value: unknown): Uint8Array { + const normalized = normalize(value, [], new WeakSet()); + // JSON.stringify with no replacer and no space arg produces the + // canonical form modulo key ordering, which `normalize` has already + // resolved by constructing plain records with sorted keys. + return encoder.encode(JSON.stringify(normalized)); +} diff --git a/vendor/intx/agent/src/default-director.ts b/vendor/intx/agent/src/default-director.ts new file mode 100644 index 000000000..cce673977 --- /dev/null +++ b/vendor/intx/agent/src/default-director.ts @@ -0,0 +1,70 @@ +// Built-in default director, packaged through the new env-DI surface. +// +// `defaultDirectorFactory` is the `AnnotatedDirectorFactory` the agent +// harness registers under id `@intx/agent/default`. It is the canonical +// entry point for callers that do not author their own directors. +// +// The factory delegates to `@intx/inference`'s `createDefaultDirector`, +// which is already a `ReactorDirector`. The registry's director shape +// is `ReactorDirector` directly (see director-types.ts); no +// translation layer is involved. +// +// Configuration: `DefaultDirectorConfig` maps the existing +// `DefaultDirectorPolicy` fields the factory accepts. The arktype +// schema validates incoming config from `defineDirector.build(config)`. + +import { type } from "arktype"; + +import { + createDefaultDirector, + type DefaultDirectorPolicy, +} from "@intx/inference"; + +import { defineDirector } from "./director"; + +/** + * Config the default director accepts via `defineDirector.build`. The + * shape mirrors `DefaultDirectorPolicy` from `@intx/inference` modulo + * fields that are not yet exposed at the author-facing surface (the + * `afterInferenceDone` hook is a function and cannot canonicalize, so + * it stays off the public ref shape). + */ +export interface DefaultDirectorConfig { + mode?: "conversational" | "reactive"; +} + +const DefaultDirectorConfigSchema = type({ + "mode?": '"conversational" | "reactive"', +}); + +const defined = defineDirector({ + id: "@intx/agent/default", + configSchema: DefaultDirectorConfigSchema, + factory: (config, _env, agent) => { + const policy: DefaultDirectorPolicy = {}; + if (config.mode !== undefined) { + policy.mode = config.mode; + } + return createDefaultDirector( + agent.systemPrompt, + [...agent.toolDefinitions], + policy, + ); + }, +}); + +/** + * The default director factory the agent harness registers. The id is + * `@intx/agent/default`. + */ +export const defaultDirectorFactory = defined.factory; + +/** + * Convenience constructor for a `DirectorRef` referencing the default + * director with the supplied config (or `{}` for "no overrides"). + * + * The registry's `buildDefaultRef()` constructs the same ref shape; this + * export exists so author-defined `AgentDefinition` values can name the + * default director explicitly when they want to pass non-default config. + */ +export const buildDefaultDirectorRef = defined.build; diff --git a/vendor/intx/agent/src/definition.ts b/vendor/intx/agent/src/definition.ts new file mode 100644 index 000000000..afd33044f --- /dev/null +++ b/vendor/intx/agent/src/definition.ts @@ -0,0 +1,200 @@ +// `AgentDefinition` -- the portable, hashable data that names what an +// agent is. Together with `defineAgent`, it produces a deploy unit: +// hashing the definition yields a deploy hash, walking the definition +// surfaces the capability and credential grants downstream tooling +// can require approval for at deploy time, and resolving it against a +// runtime env (`createAgent(def, env)`) yields a running Agent. +// +// `AgentDefinition` deliberately holds no instance state. It is data +// passed around by callers and consumed by downstream tooling (deploy +// scaffolding, metadata registries, admin surfaces). Everything that +// is per-instance (the active inference source, the storage handle, +// the authorize callback, the audit sink, the directors registry) +// lives in the env supplied at `createAgent` time. + +import type { ToolPackagePin } from "@intx/types/tool-packages"; + +import type { AnnotatedToolFactory } from "./tool"; +import type { BaseEnv } from "./env"; +import type { DirectorRef } from "./director-types"; + +/** + * Per-source preference describing which providers and models this + * agent prefers, in order. The field is **hash-only** -- it + * participates in deploy-time hashing and grant computation but is + * not consulted for runtime source selection. The agent uses + * `env.source` for the active inference call. Downstream tooling that + * resolves preferences against available credentials sets the active + * `env.source`. Reordering or mutating this field changes the deploy + * hash; consumers must treat it as immutable across a deployment. + */ +export interface InferencePreference { + readonly provider: string; + readonly model: string; + readonly parameters?: Readonly>; +} + +/** + * The portable, hashable shape of an agent. + * + * `EnvReq` is the intersection of every contributor's env requirements + * (`BaseEnv` plus whatever each tool factory and the director declare + * via `requires`). Use `EnvRequiredByAll` (below) to compute it from a + * factory tuple; `defineAgent` does this for you. + * + * Note on the type-level enforcement: a single `ToolFactory` in + * `toolFactories` collapses `EnvRequiredByAll` to `any`, silently + * stripping the type-level requirements of every other factory in the + * same definition. The runtime `validateEnv` (presence-only) is the + * load-bearing safety guarantee; the type level is best-effort + * guidance for authors who type their factories tightly. + */ +export interface AgentDefinition { + readonly id: string; + readonly description?: string; + readonly systemPrompt: string; + readonly director?: DirectorRef; + readonly toolFactories: readonly AnnotatedToolFactory[]; + /** + * Tool-package names whose `definePlugin` factories this agent uses + * (`["@intx/tools-lsp"]`). Unlike a tool factory -- which the agent + * imports and places in `toolFactories`, so it is agent-visible -- a + * plugin package contributes NO agent-visible factory: its plugin + * factory reaches the agent only through `env.plugins`, wired by the + * host. This explicit per-agent list is therefore the only way per-step + * plugin scoping and the plugin's contributed tool grants can be known + * from the definition alone. The field is part of the hashed wire + * surface (the live->inert projector carries it), so a tampered plugin + * set fails re-verify. Absent when the agent uses no plugins. + */ + readonly plugins?: readonly string[]; + readonly capabilities: readonly string[]; + readonly inference: { + readonly sources: readonly InferencePreference[]; + }; + /** + * Free-form metadata the agent itself does not consume. The agent's + * runtime does not read this field on any path; it is a passthrough + * surface for downstream consumers -- classifiers grouping + * definitions, audit consumers filtering on deployment cohort, + * tooling rendering a definition catalog. The shape is + * `Record` deliberately rather than a richer type: + * tags are human/operator-supplied identifiers, not structured + * data, and any consumer that wants to interpret a tag's content + * does so by name agreement with the producer rather than by + * shape contract. Producers that need structured per-definition + * data should add their own field on a subtype rather than nesting + * encoded JSON in a tag value. + */ + readonly tags?: Readonly>; + /** + * Tool-package pins the sidecar materializes for this agent, carried on the + * definition so a folded workflow asset is self-contained rather than + * depending on pins supplied only at deploy time. Plain-data mirror of the + * pins the deploy-tree tool channel consumes. + */ + readonly toolPackagePins?: readonly ToolPackagePin[]; +} + +// Type-level helper for computing the intersection of env requirements +// across a tuple of tool factories. + +type UnionToIntersection = ( + U extends unknown ? (k: U) => void : never +) extends (k: infer I) => void + ? I + : never; + +type EnvRequiredBy = F extends AnnotatedToolFactory ? E : never; + +/** + * Intersection of env requirements across a tuple of annotated tool + * factories, narrowed to extend `BaseEnv`. + * + * Function parameters are contravariant under TypeScript's strict mode, + * so the tuple's element constraint must be `AnnotatedToolFactory` + * rather than `AnnotatedToolFactory`. A factory typed + * `AnnotatedToolFactory` is **not** assignable to + * `AnnotatedToolFactory` (it accepts only `MailEnv`, not every + * `BaseEnv`), but it is assignable to `AnnotatedToolFactory`. The + * runtime `validateEnv` is the load-bearing safety guarantee; the + * type level is best-effort guidance. + * + * **Author-facing footgun.** A single `AnnotatedToolFactory` in + * the tuple collapses the intersection to `any` and silently strips + * the type-level env requirements of every other factory in the same + * `defineAgent` call. Third-party factories typed `` -- whether + * by oversight or by deliberate escape -- erase the compile-time + * check that the env shape covers their declared `requires`. The + * runtime `validateEnv` will still blame the missing keys at + * construction, but the author loses the editor-time feedback that + * makes env-DI cheap to use. When importing third-party tool + * factories, prefer ones whose env shape is explicit, and treat an + * `` factory the same way you would treat an `any`-typed + * variable elsewhere in the codebase: an opt-out of the type system, + * not a default. + * + * See the note on `AgentDefinition` above. + */ +export type EnvRequiredByAll< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TypeScript cannot express "factory whose env is some subtype of BaseEnv" without contravariant escape; see comment above + Factories extends readonly AnnotatedToolFactory[], +> = UnionToIntersection> & BaseEnv; + +/** + * Configuration accepted by `defineAgent`. Mirrors `AgentDefinition` + * but takes `tools` as the input field name (matching the spec's + * authoring-time shape) and infers `EnvReq` from the supplied + * factories. + */ +export interface DefineAgentConfig< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- contravariant escape per the explanation on EnvRequiredByAll above + Factories extends readonly AnnotatedToolFactory[], +> { + readonly id: string; + readonly description?: string; + readonly systemPrompt: string; + readonly director?: DirectorRef; + readonly tools: Factories; + /** Plugin-package names this agent uses; see `AgentDefinition.plugins`. */ + readonly plugins?: readonly string[]; + readonly capabilities: readonly string[]; + readonly inference: { + readonly sources: readonly InferencePreference[]; + }; + readonly tags?: Readonly>; +} + +/** + * Construct an `AgentDefinition` from authoring-time config. The + * returned definition has its env requirement computed as the + * intersection of every supplied factory's `EnvReq`. + */ +export function defineAgent< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- contravariant escape per the explanation on EnvRequiredByAll above + const Factories extends readonly AnnotatedToolFactory[], +>( + config: DefineAgentConfig, +): AgentDefinition> { + type EnvReq = EnvRequiredByAll; + // The widened factory tuple is structurally identical; the cast + // adjusts the type's `EnvReq` parameter to match the inferred + // intersection. + const toolFactories = + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- adjusting EnvReq parameter; structurally identical + config.tools as unknown as readonly AnnotatedToolFactory[]; + const definition: AgentDefinition = { + id: config.id, + systemPrompt: config.systemPrompt, + toolFactories, + capabilities: config.capabilities, + inference: config.inference, + ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), + ...(config.description !== undefined + ? { description: config.description } + : {}), + ...(config.director !== undefined ? { director: config.director } : {}), + ...(config.tags !== undefined ? { tags: config.tags } : {}), + }; + return definition; +} diff --git a/vendor/intx/agent/src/director-registry.ts b/vendor/intx/agent/src/director-registry.ts new file mode 100644 index 000000000..ee5f25fa7 --- /dev/null +++ b/vendor/intx/agent/src/director-registry.ts @@ -0,0 +1,116 @@ +// Per-runtime director registry implementation. +// +// `createDirectorRegistry({ factories, defaultId })` builds a registry +// from a flat list of `AnnotatedDirectorFactory` values and a designated +// default id. Id collisions and a missing default fail at construction +// rather than first lookup. `createDefaultDirectorRegistry()` is the +// canonical built-ins-only registry the agent harness ships for callers +// that do not author their own directors. + +import type { + AnnotatedDirectorFactory, + DirectorRef, + DirectorRegistry, +} from "./director-types"; +import type { BaseEnv } from "./env"; +import { defaultDirectorFactory } from "./default-director"; + +/** + * Erased annotated-factory shape the registry stores. `Config` is + * widened to `unknown` so factories with different configuration types + * can coexist in the same registry without contravariant assignment + * failures. + */ +type RegisteredFactory = AnnotatedDirectorFactory; + +/** + * Thrown by `DirectorRegistry.resolve` when the supplied ref names an + * id the registry does not contain. The error is named separately from + * `Error` so callers (specifically `validateEnv`) can distinguish an + * unknown-id failure from other runtime faults a custom `directors` + * implementation might raise. Custom `DirectorRegistry` implementations + * are expected to throw `UnknownDirectorIdError` on the unknown-id + * path; anything else propagates as a real failure. + */ +export class UnknownDirectorIdError extends Error { + readonly directorId: string; + + constructor(directorId: string) { + super(`unknown director in registry: ${directorId}`); + this.name = "UnknownDirectorIdError"; + this.directorId = directorId; + } +} + +/** + * Build a director registry from a flat list of factories. Throws + * `Error` at construction on duplicate ids or when `defaultId` is not + * present in `factories`. + */ +export function createDirectorRegistry(opts: { + readonly factories: readonly RegisteredFactory[]; + readonly defaultId: string; +}): DirectorRegistry { + const byId = new Map(); + for (const factory of opts.factories) { + if (byId.has(factory.id)) { + throw new Error(`director id collision in registry: ${factory.id}`); + } + byId.set(factory.id, factory); + } + + const defaultFactory = byId.get(opts.defaultId); + if (defaultFactory === undefined) { + throw new Error( + `default director ${opts.defaultId} not in registry factories`, + ); + } + + return { + resolve(ref: DirectorRef): RegisteredFactory { + const factory = byId.get(ref.id); + if (factory === undefined) { + throw new UnknownDirectorIdError(ref.id); + } + return factory; + }, + defaultFactory(): RegisteredFactory { + return defaultFactory; + }, + buildDefaultRef(): DirectorRef { + // Construct fresh each call. There is no module-load constant for + // the default ref; the spec is explicit about avoiding implicit + // module-load side effects in the director surface. + return { id: defaultFactory.id, config: {} }; + }, + }; +} + +/** + * The canonical built-ins-only registry. Convenience for callers that + * do not ship their own director factories. Callers with custom + * factories pass them into `createDirectorRegistry` directly. + */ +export function createDefaultDirectorRegistry(): DirectorRegistry { + return createDirectorRegistry({ + factories: [defaultDirectorFactory], + defaultId: defaultDirectorFactory.id, + }); +} + +/** + * Build the director registry for a workflow closure: the built-in default + * plus the closure's own `defineDirector` factories. A closure that ships no + * directors passes `loaded: []` and composes to `[defaultDirectorFactory]` -- + * identical to `createDefaultDirectorRegistry`. A closure director whose id + * shadows the built-in (or another loaded director) throws at construction, + * the same fail-loud `createDirectorRegistry` applies to any duplicate. + */ +export function createWorkflowDirectorRegistry( + loaded: readonly AnnotatedDirectorFactory[], +): DirectorRegistry { + return createDirectorRegistry({ + factories: [defaultDirectorFactory, ...loaded], + defaultId: defaultDirectorFactory.id, + }); +} diff --git a/vendor/intx/agent/src/director-types.ts b/vendor/intx/agent/src/director-types.ts new file mode 100644 index 000000000..9788f4f93 --- /dev/null +++ b/vendor/intx/agent/src/director-types.ts @@ -0,0 +1,111 @@ +// Type-only surface for the director registry. +// +// The runtime implementations -- `createDirectorRegistry`, +// `defineDirector`, and the built-in default factory -- live in +// adjacent files. This module holds only the type-level shapes the env +// contract (`BaseEnv`) depends on, so the env primitives can typecheck +// independently. +// +// `DirectorFactory` returns a `ReactorDirector` directly. The only +// director that flows through the registry today is the built-in +// default, which is already `ReactorDirector`-shaped; no translation +// layer is needed. + +import type { ReactorDirector, ToolDefinition } from "@intx/types/runtime"; + +import type { BaseEnv } from "./env"; + +/** + * Agent-instance properties a director factory needs at construction. + * Sourced from the `AgentDefinition` the agent harness is instantiating: + * the system prompt and the resolved tool definitions the model will + * see. Held separately from `BaseEnv` because these values are derived + * from the agent definition, not supplied by the caller as runtime env. + * + * `compactorNames` is the exception to the "derived from definition" + * shape: it lists the names the deployer registered on `env.compactors` + * and is surfaced here so the director picks a known name to pass to + * `caps.compact(name, reason)`. The list is empty when the deployer + * omits the env field. The shape mirrors `toolDefinitions` for the + * same reason: a director that emits an action keyed by name benefits + * from learning the registered names at construction rather than + * trusting the deployer by convention. + */ +export interface DirectorAgentContext { + readonly systemPrompt: string; + readonly toolDefinitions: readonly ToolDefinition[]; + readonly compactorNames: readonly string[]; +} + +/** + * Reference to a director shipped with a bundle. The package-namespaced + * id is the identity. The bundle that ships the director includes the + * factory that maps the id back to runtime code; same bundle = same + * factory, so no separate `factoryHash` is needed. + * + * `config` is canonical-JSON-serializable so deploy-hash consumers can + * stably hash the ref via `canonicalizeForHash(ref.config)`. + */ +export interface DirectorRef { + readonly id: string; + readonly config: Config; +} + +/** + * Factory function shape that produces a `ReactorDirector` from a + * validated config, the agent's runtime env, and the agent-instance + * context (`DirectorAgentContext`: system prompt, resolved tool + * definitions, registered compactor names). The implementation lives + * in the same bundle as the agent definition; the registry resolves it + * from `DirectorRef.id`. + */ +export type DirectorFactory< + Config = unknown, + EnvReq extends BaseEnv = BaseEnv, +> = ( + config: Config, + env: EnvReq, + agent: DirectorAgentContext, +) => ReactorDirector; + +/** + * Arktype validator for a director's config. Stored as `unknown` at the + * type level so this module does not have to import arktype; the + * concrete `defineDirector` runtime validates it. + */ +export type DirectorConfigSchema = unknown; + +/** + * Runtime metadata attached to a `DirectorFactory` by `defineDirector`. + * The factory carries its package-namespaced id, its env-key + * requirements, and the arktype schema that validates its config. + */ +export interface DirectorFactoryMeta { + readonly id: string; + readonly requires: readonly string[]; + readonly configSchema: DirectorConfigSchema; +} + +/** + * A director factory with its runtime metadata attached. The registry + * stores these; `defineDirector` produces them. + */ +export type AnnotatedDirectorFactory< + Config = unknown, + EnvReq extends BaseEnv = BaseEnv, +> = DirectorFactory & DirectorFactoryMeta; + +/** + * Per-runtime director registry. Populated explicitly at startup from + * the bundle's `defineDirector` calls plus built-ins from `@intx/agent`. + * No module-load side effects. + * + * `resolve` returns the factory for a given ref; `defaultFactory` is the + * canonical built-in; `buildDefaultRef` constructs the default ref on + * demand (each call constructs a fresh object, no module-load constant). + */ +export interface DirectorRegistry { + resolve(ref: DirectorRef): AnnotatedDirectorFactory; + defaultFactory(): AnnotatedDirectorFactory; + buildDefaultRef(): DirectorRef; +} diff --git a/vendor/intx/agent/src/director.ts b/vendor/intx/agent/src/director.ts new file mode 100644 index 000000000..fcd448d10 --- /dev/null +++ b/vendor/intx/agent/src/director.ts @@ -0,0 +1,175 @@ +// `defineDirector` -- the env-DI factory shape for author-defined +// directors. +// +// `defineDirector({ id, configSchema, requires?, factory })` returns +// `{ build, factory }`. The `factory` is an `AnnotatedDirectorFactory` +// the registry stores by id; the bundle that ships the director +// re-exports it so a caller can pass it into `createDirectorRegistry`. +// The `build(config)` constructor produces a `DirectorRef` from a +// config that the schema validates. Both halves are needed: the +// registry stores the factory, the agent definition stores the ref. +// +// The `defineDirector` runtime does not register the factory as a +// module-load side effect. Each runtime instance constructs its +// registry explicitly via `createDirectorRegistry`, listing the +// factories it wants rather than relying on import-order. + +import { type } from "arktype"; + +import type { + AnnotatedDirectorFactory, + DirectorConfigSchema, + DirectorFactory, + DirectorRef, +} from "./director-types"; +import type { BaseEnv } from "./env"; +import { validateNamespacedId } from "./namespace"; +import { isAnnotatedPluginFactory } from "./tool"; + +/** + * Result of `defineDirector`. The `factory` half is what the registry + * stores; the `build` half is what the agent-definition author calls + * to construct a `DirectorRef` referencing this director. + * + * The `factory` field is **type-erased** in its `Config` parameter. The + * registry stores factories of heterogeneous config types alongside + * each other; if `factory` carried the narrow `Config`, contravariant + * function-parameter variance would prevent the assignment. The agent + * only invokes the factory with `ref.config: unknown` sourced from a + * `DirectorRef`, and the schema has already validated that config at + * `build` time, so the erasure is safe at the call site. + */ +export interface DefinedDirector { + readonly factory: AnnotatedDirectorFactory; + build(config: Config): DirectorRef; +} + +/** + * Define a director factory. + * + * - `id` must be package-namespaced. Bare ids throw at definition + * time. + * - `configSchema` is an arktype validator. The schema validates the + * config at `build(config)` time. Consumers that compute a deploy + * hash over the ref call `canonicalizeForHash(ref.config)` + * themselves; `build` does not run that check. + * - `requires` enumerates the env keys the factory touches beyond + * `BaseEnv`. `validateEnv` checks presence at instantiation. + * - `factory(config, env, agentContext)` returns a `ReactorDirector`. + * `agentContext` carries the agent definition's resolved system + * prompt and tool definitions; the factory uses them when its + * director needs to see the model's tools or seed prompt. + * + * Two-stage construction (factory + build) lets the registry index the + * factory by id while callers stamp configs into refs as data. Same + * bundle = same factory; refs hash by id and config. + */ +export function defineDirector(opts: { + readonly id: string; + readonly configSchema: DirectorConfigSchema; + readonly requires?: readonly string[]; + readonly factory: DirectorFactory; +}): DefinedDirector { + validateNamespacedId(opts.id); + + const requires = Object.freeze([ + ...(opts.requires ?? []), + ]) as readonly string[]; + + // Wrap the caller's factory rather than mutating it. A caller that + // shares a factory function across multiple `defineDirector` calls + // (e.g. registering the same factory under two ids) needs each + // annotated factory to be a distinct identity with its own metadata; + // a direct `Object.assign` on `opts.factory` would let the second + // call silently overwrite the first's annotations. + const wrapped: DirectorFactory = (config, env, agent) => + opts.factory(config, env, agent); + const annotatedTyped: AnnotatedDirectorFactory = + Object.assign(wrapped, { + id: opts.id, + requires, + configSchema: opts.configSchema, + }); + // Erase the Config parameter for registry storage. The factory body + // continues to expect the narrow Config (via the closure on + // `opts.factory`); the registry just sees `(config: unknown, ...)`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentional contravariant erasure for heterogeneous registry storage + const annotated = annotatedTyped as unknown as AnnotatedDirectorFactory< + unknown, + EnvReq + >; + + function build(config: Config): DirectorRef { + validateConfig(config, opts.configSchema); + return { id: opts.id, config }; + } + + return { factory: annotated, build }; +} + +function validateConfig(config: unknown, schema: DirectorConfigSchema): void { + // The schema is typed as `unknown` at the type level so this module + // does not have to import arktype. At runtime it must be an arktype + // validator -- a callable that returns either the validated value or + // a `type.errors` instance. If the schema is not callable, treat + // that as a definition-time author error. + if (typeof schema !== "function") { + throw new Error( + "director configSchema must be an arktype validator (callable)", + ); + } + const result: unknown = schema(config); + if (result instanceof type.errors) { + throw new Error(`director config validation failed: ${result.summary}`); + } +} + +/** + * Run the registered config schema against a `DirectorRef.config`. + * Throws on schema rejection or on a non-callable schema. + * + * `defineDirector.build(config)` runs this at definition-construction + * time. `createAgent` runs it again at resolve time so a caller that + * hand-constructs a `DirectorRef` (the type is public; nothing forces + * refs through `build`) cannot bypass the schema check and hand a + * malformed config to the factory. + */ +export function validateDirectorConfig( + config: unknown, + schema: DirectorConfigSchema, +): void { + validateConfig(config, schema); +} + +/** + * Structural check for an `AnnotatedDirectorFactory` export. The shape is + * callable + `{ id: string, requires: string[], configSchema: function }`. + * The `configSchema` field is the discriminator against tool factories + * (which carry only `id` and `requires`); without it, any tool-factory + * export from a directors-entry module would be accepted as a director. + * + * Shared by the tool-package loader (`@intx/tool-packaging`) and the + * workflow-closure director loader (`@intx/workflow-host`) so both accept + * and reject exactly the same shapes -- one accept/reject rule the + * approval-time probe and the runtime cannot drift apart on. Two copies of + * "is this a valid director" would be a silent congruence hole. + */ +export function isAnnotatedDirectorFactory( + value: unknown, +): value is AnnotatedDirectorFactory { + if (typeof value !== "function") return false; + if (isAnnotatedPluginFactory(value)) return false; + if (!("id" in value) || !("requires" in value)) return false; + if (!("configSchema" in value)) return false; + const id = (value as { id: unknown }).id; + const requires = (value as { requires: unknown }).requires; + const configSchema = (value as { configSchema: unknown }).configSchema; + if (typeof id !== "string") return false; + if (!Array.isArray(requires)) return false; + if (!requires.every((r) => typeof r === "string")) return false; + // `defineDirector` requires a callable arktype validator. A non-callable + // schema would crash later inside config validation; reject here so the + // failure surfaces at load time rather than at first config-validation. + if (typeof configSchema !== "function") return false; + return true; +} diff --git a/vendor/intx/agent/src/env-validation.ts b/vendor/intx/agent/src/env-validation.ts new file mode 100644 index 000000000..8430dbc70 --- /dev/null +++ b/vendor/intx/agent/src/env-validation.ts @@ -0,0 +1,214 @@ +// Presence-only env validation. +// +// `validateEnv(def, env)` walks the env keys every contributor in the +// definition declared and asserts each is present and non-nullish on +// the supplied env. The check is structural-shallow: a key is "present" +// when `env[key] !== undefined && env[key] !== null`. Value shape is +// not validated -- tool factories whose env contents are structurally +// wrong are expected to fail loud at construction. +// +// `getRequiredEnvKeys(def, registry)` returns the env-key surface in +// a `RequiredEnvKeys` struct alongside an `unresolvedDirectorId` field +// that surfaces the registry's inability to resolve the definition's +// director (so a UI consumer learns both pieces of information in a +// single call). `validateEnv` walks the key set inline rather than +// delegating to this helper -- it needs per-key blame metadata that +// the flat key list does not carry -- so the two functions stay in +// sync through the shared `BASE_ENV_KEYS` constant and the +// `effectiveDirectorRef` helper below. +// +// `effectiveDirectorRef(def, registry)` is the shared helper both +// `validateEnv` and `getRequiredEnvKeys` use to normalize the +// absent-director case. Defined once so the absent-director shape +// stays consistent across callers. + +import type { AgentDefinition } from "./definition"; +import { UnknownDirectorIdError } from "./director-registry"; +import type { DirectorRef, DirectorRegistry } from "./director-types"; +import { type BaseEnv, AgentEnvError } from "./env"; + +const BASE_ENV_KEYS = [ + "sources", + "defaultSource", + "storage", + "workdir", + "audit", + "authorize", + "directors", +] as const; + +/** + * The director ref the agent will resolve against the registry. Falls + * back to the registry's canonical default when the definition omits a + * director. Used identically by `validateEnv` and + * `getRequiredEnvKeys` so the absent-director normalization is + * consistent. + * + * The parameter is typed as `Pick, "director">` + * rather than the full `AgentDefinition` because the function + * only reads `def.director`, which is invariant in `EnvReq`. The + * `Pick` is a structural supertype of every `AgentDefinition` + * so every caller passes its own narrower generic without an unsafe + * cast at the call site. + */ +export function effectiveDirectorRef( + def: Pick, "director">, + registry: DirectorRegistry, +): DirectorRef { + return def.director ?? registry.buildDefaultRef(); +} + +/** + * The result of `getRequiredEnvKeys`. `keys` is the env-key set the + * supplied definition's tools and director declare (plus the + * `BaseEnv` core keys). `unresolvedDirectorId` is `null` when the + * director resolved cleanly and the keys list is complete; non-null + * when the registry could not resolve the definition's director, in + * which case `keys` is the best partial answer (BaseEnv + tool keys + * only -- the director's `requires` could not be enumerated). + * + * `unresolvedDirectorId` is `string | null` rather than an optional + * field so the caller has to acknowledge it exists; an optional that + * resolves to `undefined` is too easy to ignore. + */ +export interface RequiredEnvKeys { + readonly keys: readonly string[]; + readonly unresolvedDirectorId: string | null; +} + +/** + * Returns the env-key surface the supplied definition declares via + * `BaseEnv`, tool factory `requires`, and the resolved director's + * `requires`. When the registry does not contain the definition's + * director, the returned `keys` is the best partial answer (BaseEnv + * + tool keys only) and the unresolved id surfaces on + * `unresolvedDirectorId` so a single call answers both "what env + * keys must I populate?" and "did the director resolve?". + */ +export function getRequiredEnvKeys( + def: AgentDefinition, + registry: DirectorRegistry, +): RequiredEnvKeys { + const keys = new Set(BASE_ENV_KEYS); + for (const factory of def.toolFactories) { + for (const key of factory.requires) { + keys.add(key); + } + } + const ref = effectiveDirectorRef(def, registry); + let unresolvedDirectorId: string | null = null; + try { + const directorFactory = registry.resolve(ref); + for (const key of directorFactory.requires) { + keys.add(key); + } + } catch (cause) { + // Same policy as `validateEnv`: only swallow the documented + // unknown-id case. Other faults from a custom registry propagate + // so the caller sees the real exception. + if (!(cause instanceof UnknownDirectorIdError)) throw cause; + unresolvedDirectorId = ref.id; + } + return Object.freeze({ + keys: Object.freeze([...keys]), + unresolvedDirectorId, + }); +} + +/** + * Presence-only env validation. Throws `AgentEnvError` listing every + * missing key, the contributors that declared each one, and any + * director ids the registry could not resolve. + * + * `BaseEnv` contributes its core keys; each tool factory + * contributes under the label `tool:`; the director contributes + * under `director:`. Multiple contributors blaming the same + * missing key collapse into a single error. Unknown director ids land + * on the error's separate `unresolvedDirectors` field rather than + * being mixed into `missing` (env keys) so consumers can distinguish + * the two failure modes. + */ +export function validateEnv( + def: AgentDefinition, + env: EnvReq, +): void { + const missing = new Set(); + const blame = new Set(); + // Per-contributor map of the keys that contributor declared as + // missing. Built in parallel with the flat `missing` / `blame` + // sets so we can surface the contributor → key association without + // changing the flat-array shape callers already consume. + const byContributor = new Map>(); + const noteMissing = (key: string, contributor: string): void => { + missing.add(key); + blame.add(contributor); + let bucket = byContributor.get(contributor); + if (bucket === undefined) { + bucket = new Set(); + byContributor.set(contributor, bucket); + } + bucket.add(key); + }; + const unresolvedDirectors = new Set(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- shape-erase env to index by key without enumerating the union of generic env keys + const envRecord = env as unknown as Record; + + for (const key of BASE_ENV_KEYS) { + const value = envRecord[key]; + if (value === undefined || value === null) { + noteMissing(key, "BaseEnv"); + } + } + + for (const factory of def.toolFactories) { + for (const key of factory.requires) { + const value = envRecord[key]; + if (value === undefined || value === null) { + noteMissing(key, `tool:${factory.id}`); + } + } + } + + // Director resolution can throw on unknown ids. Presence of the + // director registry itself is already covered by the BaseEnv loop + // above. If the registry is missing here, we have already recorded + // it and cannot dereference -- short-circuit on that case. If + // resolve throws (unknown director id), surface the id through + // AgentEnvError's `unresolvedDirectors` field so the caller gets a + // single uniform exception path while still being able to + // distinguish "missing env key" from "unknown director id" + // programmatically. + if (env.directors !== undefined && env.directors !== null) { + // `effectiveDirectorRef` is `Pick`-shaped + // -- every `AgentDefinition` is a structural supertype of + // that pick, so no cast is needed here. + const ref = effectiveDirectorRef(def, env.directors); + try { + const directorFactory = env.directors.resolve(ref); + for (const key of directorFactory.requires) { + const value = envRecord[key]; + if (value === undefined || value === null) { + noteMissing(key, `director:${directorFactory.id}`); + } + } + } catch (cause) { + // Only catch the documented unknown-id case. Other faults from a + // custom registry (TypeError on a malformed ref, an internal + // Map failure, etc.) propagate so the caller sees the real + // exception rather than a silently-relabelled "unresolved + // director id." + if (!(cause instanceof UnknownDirectorIdError)) throw cause; + unresolvedDirectors.add(ref.id); + } + } + + if (missing.size > 0 || unresolvedDirectors.size > 0) { + const frozenByContributor = new Map(); + for (const [contributor, keys] of byContributor) { + frozenByContributor.set(contributor, Object.freeze([...keys])); + } + throw new AgentEnvError([...missing], [...blame], frozenByContributor, [ + ...unresolvedDirectors, + ]); + } +} diff --git a/vendor/intx/agent/src/env.ts b/vendor/intx/agent/src/env.ts new file mode 100644 index 000000000..08e462fcc --- /dev/null +++ b/vendor/intx/agent/src/env.ts @@ -0,0 +1,235 @@ +// The agent's runtime environment contract. +// +// `BaseEnv` is what every `createAgent(def, env)` call requires. Tools +// and directors may extend it with additional keys declared via their +// `defineTool` / `defineDirector` `requires` metadata; the runtime +// `validateEnv` (see `env-validation.ts`) +// asserts presence of every declared key before the agent constructs a +// reactor. +// +// `audit`, `authorize`, and `directors` are required fields. There are +// no read-site defaults: a caller that omits them is making an +// affirmative choice the env contract rejects. No-op implementations for +// tests and examples ship from `@intx/agent/testing`. + +import type { AuthzCallResult, Dependencies } from "@intx/inference"; +import type { + AuditStore, + Compactor, + ContextStore, + InferenceSource, +} from "@intx/types/runtime"; + +import type { DirectorRegistry } from "./director-types"; + +// `Dependencies` is re-exported from `@intx/inference`, where the +// reactor assembly owns its canonical shape. +export type { Dependencies }; + +/** + * Authorization callback shape. Tools call `authorize` before invoking; + * the reactor assembly's authz extension threads the call through. The + * shape matches `@intx/inference`'s `AuthzExtensionOptions.authorize`. + * + * `Ctx` parameterizes the per-call context the closure receives. + * `unknown` is the default: bare callers do not interpret it. Higher- + * layer runtimes that have a richer notion of context (the workflow + * runtime supplies `{ stepId, attempt, runId }` via `@intx/workflow`'s + * `AuthorizeContext`) construct a closure whose third arg is ignored + * and which delegates to a runtime-typed authorize with the context + * captured at closure-build time. The third arg in the public signature + * is plumbing so the inference layer can pass through whatever shape + * the caller's runtime chooses without learning that runtime's + * vocabulary. + */ +export type AuthorizeFn = ( + resource: string, + action: string, + context: Ctx, +) => Promise; + +/** + * Required base env for every agent. Tools declare additional keys via + * `defineTool({ requires })`; directors via `defineDirector({ requires })`. + * + * `audit` and `authorize` are required. `directors` is required so + * `createAgent` can resolve the agent definition's `DirectorRef` (or + * fall back to the registry's canonical default) without invoking any + * read-site fallback. + */ +export interface BaseEnv { + /** + * Ordered inference sources supplied at instantiation. The agent copies + * these into its own internal source registry; the head of the priority + * order (the source whose id is `defaultSource`) starts active, and the + * tail is the failover chain. Later `setSource`/`setSources` calls mutate + * the registry's copy, not these objects. + */ + sources: InferenceSource[]; + + /** Id of the source that starts active (the head of the priority order). */ + defaultSource: string; + + /** Backing context store. The caller owns its lifetime. */ + storage: ContextStore; + + /** + * The directory the agent treats as its singleton lock boundary. + * + * For isogit-backed storage this MUST equal the directory passed to + * `createIsogitStore`. Two agents constructed against the same + * `workdir` fail the lock; two agents constructed against differing + * `workdir` values pointing at the same on-disk storage directory + * will silently corrupt each other -- the invariant is the caller's + * to maintain. + */ + workdir: string; + + /** Audit sink. Required; no read-site fallback. */ + audit: AuditStore; + + /** Authorization callback. Required; no read-site fallback. */ + authorize: AuthorizeFn; + + /** Director registry. Required; no read-site fallback. */ + directors: DirectorRegistry; + + /** + * Compactors registered for this deployment, keyed by name. The + * director picks a registered name and emits + * `caps.compact(name, reason)`; the reactor resolves the name against + * this map and runs the compactor's `apply()` on the conversation + * turns. Registered names are surfaced to the director factory at + * construction via `agentContext.compactorNames` so the director + * picks against a known set rather than guessing by convention. + * + * Optional: omitting the field is the same shape as registering an + * empty map. A `caps.compact(name, …)` call against an absent or + * empty registry produces the reactor's existing + * "no compactor registered" fatal error. + * + * Field placement mirrors `directors`: "what's registered at this + * deployment" is an env question, not an agent-definition question. + */ + compactors?: Record; + + /** + * Inference dependencies (notably `fetch` and the adapter registry) for + * the reactor's underlying `runInference` call. + * + * Production callers omit this field -- `createAgent` fills it from + * `@intx/inference/providers`' `createDefaultDependencies()`, which binds + * `globalThis.fetch` and the built-in adapter registry. Pass an explicit + * `Dependencies` to override: tests supply `setupHarness().deps` from + * `@intx/inference-testing` for a deterministic stub fetch, and hosts with + * custom adapters pass a registry built via `loadAdapterRegistry`. + * + * Optional; do not require this field on the production path. + */ + deps?: Dependencies; + + /** + * Optional deterministic session id. Production callers omit and let + * the agent generate a fresh UUID; tests that assert on audit-record + * sessionIds supply a stable value. + */ + sessionId?: string; + + /** + * Override for the default 10 000-character tool-result size cap. + * Forwarded to the reactor assembly's size-cap transform. + */ + sizeCapMaxChars?: number; + + /** + * Override for the default doom-loop detection threshold: the number of + * identical consecutive tool-call turns that ends the run (default 3). + * Forwarded to the reactor. Must be a positive integer, or `false` to + * disable doom-loop detection entirely. + */ + doomLoopThreshold?: number | false; + + /** + * Maximum number of pending sends (active + queued). Beyond this, + * `send()` rejects with `SendQueueFullError`. Defaults to 16. + */ + sendQueueMax?: number; + + /** + * Maximum events any single `stream()` consumer may buffer. Beyond + * this, the next read on that consumer's iterator throws + * `StreamBackpressureError`; other consumers are unaffected. Defaults + * to 1024. + */ + streamBufferMax?: number; + + /** + * Maximum milliseconds `close()` waits for the reactor's shutdown + * sequence (audit flush, in-flight commits) before releasing the + * lock and returning. Defaults to 5000. Zero disables the wait + * (useful for tests whose reactor shutdown is intentionally blocked). + */ + closeTimeoutMs?: number; + + /** + * Plugin instances produced by plugin factories the host loaded + * before instantiating tool factories. Each entry is the value the + * plugin factory returned (`AnnotatedPluginFactory`'s `Result`). + * + * Tool packages that accept plugins read this field and filter for + * plugins they recognise (by structural shape or a kind marker the + * host-side packages agree on). The agent runtime delivers plugins + * without interpreting them — composition is the receiving tool + * package's responsibility. + */ + plugins?: readonly unknown[]; +} + +/** + * Thrown by `validateEnv` when the env-shape check fails. Two failure + * modes are reported separately so consumers can react to each: + * + * - `missing` lists the env key names that were absent. `contributors` + * lists every tool / director / `BaseEnv` label that declared at + * least one missing key (`BaseEnv` is the contributor for the six + * core fields). `missingByContributor` pairs each contributor with + * the specific keys it declared as missing so consumers can render + * an error UI that tells the author which factory blamed which key + * (the flat `missing` and `contributors` arrays carry the same data + * without the join). + * - `unresolvedDirectors` lists the `DirectorRef.id`s the registry + * could not resolve (the agent definition referenced a director the + * registry does not contain). These land on a separate field rather + * than being mixed into `missing` (env-key names) so consumers can + * distinguish the two failure modes programmatically. + */ +export class AgentEnvError extends Error { + readonly missing: readonly string[]; + readonly contributors: readonly string[]; + readonly missingByContributor: ReadonlyMap; + readonly unresolvedDirectors: readonly string[]; + + constructor( + missing: readonly string[], + contributors: readonly string[], + missingByContributor: ReadonlyMap = new Map(), + unresolvedDirectors: readonly string[] = [], + ) { + const parts: string[] = []; + if (missing.length > 0) { + parts.push( + `missing required keys: ${missing.join(", ")} ` + + `(required by: ${contributors.join(", ")})`, + ); + } + if (unresolvedDirectors.length > 0) { + parts.push(`unresolved director ids: ${unresolvedDirectors.join(", ")}`); + } + super(`agent env validation failed: ${parts.join("; ")}`); + this.name = "AgentEnvError"; + this.missing = missing; + this.contributors = contributors; + this.missingByContributor = missingByContributor; + this.unresolvedDirectors = unresolvedDirectors; + } +} diff --git a/vendor/intx/agent/src/index.ts b/vendor/intx/agent/src/index.ts new file mode 100644 index 000000000..276605317 --- /dev/null +++ b/vendor/intx/agent/src/index.ts @@ -0,0 +1,99 @@ +// @intx/agent — in-process agent runtime. +// +// Sits on top of `createReactorAssembly` from `@intx/inference` to +// provide a code-driven agent surface: send a message, stream events, +// project history, hot-swap inference sources. Peer to `@intx/harness`; +// the harness drives the reactor from a mail transport (INBOX watch, +// connector threads, outbound replies via MessageTransport) while the +// agent drives it from in-process calls. + +export { AgentContextLockError } from "./lock"; +export { + type AgentTool, + type AgentToolRunner, + type AnnotatedPluginFactory, + type AnnotatedPluginMeta, + type AnnotatedToolFactory, + type PluginFactory, + type StringToolHandler, + type ToolBundle, + type ToolDeclaration, + type ToolFactory, + type ToolFactoryMeta, + type ToolHandler, + DuplicateToolError, + PLUGIN_MARKER, + TOOL_PLUGIN_KIND, + type ToolPluginKind, + createToolRunner, + defineTool, + definePlugin, + fromToolRunner, + isAnnotatedPluginFactory, + isToolPluginInstance, + stringTool, + tool, + toolApprovalEffect, +} from "./tool"; +export { + type AuthorizeFn, + type BaseEnv, + type Dependencies, + AgentEnvError, +} from "./env"; +export { + type AnnotatedDirectorFactory, + type DirectorAgentContext, + type DirectorConfigSchema, + type DirectorFactory, + type DirectorFactoryMeta, + type DirectorRef, + type DirectorRegistry, +} from "./director-types"; +export { validateNamespacedId } from "./namespace"; +export { CanonicalizationError, canonicalizeForHash } from "./canonicalize"; +export { + type DefinedDirector, + defineDirector, + isAnnotatedDirectorFactory, +} from "./director"; +export { + createDefaultDirectorRegistry, + createDirectorRegistry, + createWorkflowDirectorRegistry, + UnknownDirectorIdError, +} from "./director-registry"; +export { + type DefaultDirectorConfig, + buildDefaultDirectorRef, + defaultDirectorFactory, +} from "./default-director"; +export { + type SourceRegistry, + InvalidInferenceSourceError, + SourceNotFoundError, + createSourceRegistry, +} from "./source"; +export { + type Agent, + type SendOptions, + type SendResult, + AgentClosedError, + GateSuspendedWithoutCorrelationError, + createAgent, +} from "./agent"; +export { + type AgentDefinition, + type DefineAgentConfig, + type EnvRequiredByAll, + type InferencePreference, + defineAgent, +} from "./definition"; +export { + effectiveDirectorRef, + getRequiredEnvKeys, + validateEnv, +} from "./env-validation"; +export type { RequiredEnvKeys } from "./env-validation"; +export { SendQueueFullError } from "./send-queue"; +export { StreamBackpressureError } from "./stream"; diff --git a/vendor/intx/agent/src/internal-fixtures/mail.ts b/vendor/intx/agent/src/internal-fixtures/mail.ts new file mode 100644 index 000000000..e4200d8f3 --- /dev/null +++ b/vendor/intx/agent/src/internal-fixtures/mail.ts @@ -0,0 +1,113 @@ +// Mail-participating agent fixture for the reactor-once tests. +// +// The fixture's definition has a tool factory that declares +// `requires: ["transport", "address"]`. A bare `BaseEnv` is short on +// those keys; instantiation must fail with `AgentEnvError`. A +// transport-bearing env satisfies the requirement and instantiation +// succeeds; the reactor-once assertion lives in `mail.test.ts`. +// +// The fixture does not import `@intx/harness` -- the composition +// layer's reactor-once invariant is exercised by the harness's own +// tests against its own surface; cross-importing harness here would +// introduce a `@intx/agent <-> @intx/harness` cycle. + +import type { ContextStore, InferenceSource } from "@intx/types/runtime"; + +import { defineAgent, type AgentDefinition } from "../definition"; +import { createDefaultDirectorRegistry } from "../director-registry"; +import { type BaseEnv } from "../env"; +import { noopAuditStore } from "../testing/audit-noop"; +import { permissiveAuthorize } from "../testing/authorize-allow"; +import { defineTool } from "../tool"; + +export const MAIL_SOURCE: InferenceSource = { + id: "anthropic:claude-opus-4-6", + provider: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-test-mail", + model: "claude-opus-4-6", +}; + +export const MAIL_ADDRESS = "support@fixture.local"; + +/** + * Env extension declared by the fixture's mail tool. Production code + * uses the `MailEnv` from `@intx/harness`; the fixture redeclares the + * shape locally to avoid the cross-package import. + */ +export interface FixtureMailEnv extends BaseEnv { + transport: unknown; + address: string; +} + +/** + * A no-op mail tool factory declaring `requires: ["transport", + * "address"]`. The bundle's definitions are empty -- the fixture's + * tests do not invoke any tool; they verify env validation behaviour. + */ +export const fixtureMailFactory = defineTool({ + id: "@intx-fixtures/mail/bundle", + requires: ["transport", "address"], + definitions: [], + factory: () => ({ + definitions: [], + async run(call) { + return { callId: call.id, content: "" }; + }, + }), +}); + +export const mailAgentDefinition: AgentDefinition = defineAgent( + { + id: "mail-fixture", + description: "Mail-participating fixture agent", + systemPrompt: "You handle mail.", + tools: [fixtureMailFactory], + capabilities: [], + inference: { + sources: [{ provider: MAIL_SOURCE.provider, model: MAIL_SOURCE.model }], + }, + }, +); + +/** + * Build a bare `BaseEnv` lacking `transport` and `address`. The mail + * factory's `requires` makes this env short; `validateEnv` throws + * `AgentEnvError` blaming the factory. + */ +export function createBareMailEnv(opts: { + storage: ContextStore; + workdir: string; +}): BaseEnv { + return { + sources: [MAIL_SOURCE], + defaultSource: MAIL_SOURCE.id, + storage: opts.storage, + workdir: opts.workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + }; +} + +/** + * Build a transport-bearing `FixtureMailEnv` for the success path. + * `transport` is opaque to the fixture (the mail factory's run is a + * no-op) so the test does not need to supply a real transport. + */ +export function createTransportMailEnv(opts: { + storage: ContextStore; + workdir: string; +}): FixtureMailEnv { + return { + sources: [MAIL_SOURCE], + defaultSource: MAIL_SOURCE.id, + storage: opts.storage, + workdir: opts.workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + transport: { kind: "fake-transport" }, + address: MAIL_ADDRESS, + }; +} diff --git a/vendor/intx/agent/src/internal-fixtures/planner.ts b/vendor/intx/agent/src/internal-fixtures/planner.ts new file mode 100644 index 000000000..a7adb87ee --- /dev/null +++ b/vendor/intx/agent/src/internal-fixtures/planner.ts @@ -0,0 +1,59 @@ +// Planner-shape agent fixture for the reactor-once tests. +// +// A pure in-process agent: no transport, no connector, no mail. Used by +// `planner.test.ts` to assert that `createAgent(def, env)` wraps the +// reactor exactly once per instantiation against a bare `BaseEnv`. + +import type { ContextStore, InferenceSource } from "@intx/types/runtime"; + +import { defineAgent, type AgentDefinition } from "../definition"; +import { createDefaultDirectorRegistry } from "../director-registry"; +import type { BaseEnv } from "../env"; +import { noopAuditStore } from "../testing/audit-noop"; +import { permissiveAuthorize } from "../testing/authorize-allow"; + +export const PLANNER_SOURCE: InferenceSource = { + id: "anthropic:claude-opus-4-6", + provider: "anthropic", + baseURL: "https://api.anthropic.com", + apiKey: "sk-test-planner", + model: "claude-opus-4-6", +}; + +/** + * Planner-shape definition: no tool factories, capabilities, or + * director ref. The default director from the registry handles the + * loop; the bare env covers every required `BaseEnv` key. + */ +export const plannerAgentDefinition: AgentDefinition = defineAgent({ + id: "planner", + description: "Decomposes goals into a plan", + systemPrompt: "You are the planner.", + tools: [], + capabilities: [], + inference: { + sources: [ + { provider: PLANNER_SOURCE.provider, model: PLANNER_SOURCE.model }, + ], + }, +}); + +/** + * Build a bare `BaseEnv` for the planner fixture. The caller supplies + * the storage and workdir; everything else is filled from + * `@intx/agent/testing` no-ops. + */ +export function createPlannerEnv(opts: { + storage: ContextStore; + workdir: string; +}): BaseEnv { + return { + sources: [PLANNER_SOURCE], + defaultSource: PLANNER_SOURCE.id, + storage: opts.storage, + workdir: opts.workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + }; +} diff --git a/vendor/intx/agent/src/lock.ts b/vendor/intx/agent/src/lock.ts new file mode 100644 index 000000000..a878263e4 --- /dev/null +++ b/vendor/intx/agent/src/lock.ts @@ -0,0 +1,59 @@ +// Process-wide registry of held workdir locks. +// +// The agent enforces a runtime singleton-per-workdir invariant: at most one +// in-process agent may own a given workdir at a time. Holding two agents +// against the same workdir simultaneously corrupts both the git state of +// any isogit-backed `ContextStore` rooted there and the audit collector's +// bookkeeping. +// +// This is a best-effort in-process check. It does not coordinate across OS +// processes, and it compares lexically-resolved absolute paths — two paths +// that point to the same directory through symlinks or `..`/`/./` segments +// are normalized by `path.resolve`, but a hard link or a separately mounted +// bind to the same inode will not be detected. Callers are responsible for +// ensuring `env.workdir` matches the directory backing their `env.storage` +// (see `BaseEnv.workdir` for the documented invariant). + +import { resolve } from "node:path"; + +const heldLocks = new Set(); + +export class AgentContextLockError extends Error { + readonly workdir: string; + + constructor(workdir: string) { + super(`an agent is already open for workdir: ${workdir}`); + this.name = "AgentContextLockError"; + this.workdir = workdir; + } +} + +export type ContextDirLock = { + /** Absolute, resolved path of the locked directory. */ + readonly path: string; + /** Release the lock. Idempotent. */ + release(): void; +}; + +/** + * Acquire the process-wide lock for `workdir`. Throws + * `AgentContextLockError` if another agent already holds it. The + * returned `release` is idempotent. + */ +export function acquireContextDirLock(workdir: string): ContextDirLock { + const path = resolve(workdir); + if (heldLocks.has(path)) { + throw new AgentContextLockError(path); + } + heldLocks.add(path); + + let released = false; + return { + path, + release() { + if (released) return; + released = true; + heldLocks.delete(path); + }, + }; +} diff --git a/vendor/intx/agent/src/namespace.ts b/vendor/intx/agent/src/namespace.ts new file mode 100644 index 000000000..b28a6a276 --- /dev/null +++ b/vendor/intx/agent/src/namespace.ts @@ -0,0 +1,45 @@ +// Package-namespaced id validation for tool and director factories. +// +// Ids must be either scoped ("@scope/pkg/name") or unscoped +// ("pkg/name"). The package portion is the identity anchor; the trailing +// segment names the tool or director within that package. Bare ids +// without a package portion are rejected at definition time so two +// independently-authored bundles cannot accidentally collide on an +// otherwise plausible name like "default". + +// Per-segment character set. Mirrors what npm and most package-manager +// ecosystems accept inside an id segment: alphanumerics, dot, hyphen, +// underscore. Whitespace (space, tab, newline) and other punctuation +// are excluded so an id never carries characters that would render +// strangely in error messages, break log-line parsing, or trip +// downstream tooling that splits on whitespace. +const SEGMENT = "[A-Za-z0-9._-]+"; + +// Scoped: "@scope/pkg/name". Three slash-separated segments; the first +// starts with "@" followed by the segment character set. Each segment +// must be non-empty. +const SCOPED = new RegExp(`^@${SEGMENT}\\/${SEGMENT}\\/${SEGMENT}$`); + +// Unscoped: "pkg/name". Two slash-separated segments. The package +// segment cannot start with "@" -- that route is the scoped form. +const UNSCOPED = new RegExp(`^${SEGMENT}\\/${SEGMENT}$`); + +/** + * Validate a package-namespaced id. Throws with a precise diagnostic + * when the id is not in one of the two supported shapes. + * + * "@intx/agent/default" -> ok (scoped) + * "@my-org/my-workflow/special" -> ok (scoped) + * "lodash-style/director-name" -> ok (unscoped) + * "default" -> rejected (no package portion) + * "@intx/agent" -> rejected (missing name segment) + * "@intx/agent/" -> rejected (empty name segment) + */ +export function validateNamespacedId(id: string): void { + if (!SCOPED.test(id) && !UNSCOPED.test(id)) { + throw new Error( + `id must be package-namespaced ` + + `(e.g. "@vendor/pkg/name" or "pkg/name"); got ${JSON.stringify(id)}`, + ); + } +} diff --git a/vendor/intx/agent/src/send-queue.ts b/vendor/intx/agent/src/send-queue.ts new file mode 100644 index 000000000..60774dff0 --- /dev/null +++ b/vendor/intx/agent/src/send-queue.ts @@ -0,0 +1,200 @@ +// FIFO queue for serializing send() calls against a single reactor. +// +// The agent processes one reactor cycle at a time, so concurrent send() +// callers are queued. Each queued item carries the caller's resolve/reject +// hooks and an optional AbortSignal: +// +// - If the signal is already aborted when enqueue() is called the queue +// rejects synchronously without enqueueing. +// - If the signal fires while the item is still queued the item is +// removed and rejected. +// - If the signal fires while the item is active the caller-facing +// promise rejects immediately, but the reactor cycle continues in the +// background. The queue does not start the next item until the consumer +// reports the cycle done via resolveActive/rejectActive. This keeps the +// queue ordered against actual reactor cycles — two send() promises +// cannot interleave at the reactor level. +// +// Queue depth (active + pending) is bounded by `maxDepth`; exceeding it +// throws `SendQueueFullError` synchronously from enqueue() so a buggy +// caller flooding sends fails loud instead of silently buffering. + +export class SendQueueFullError extends Error { + readonly maxDepth: number; + + constructor(maxDepth: number) { + super(`send queue is full (max depth ${String(maxDepth)})`); + this.name = "SendQueueFullError"; + this.maxDepth = maxDepth; + } +} + +type Job = { + item: T; + signal?: AbortSignal; + abortHandler?: () => void; + resolve: (value: R) => void; + reject: (reason: unknown) => void; + /** + * True once the caller-facing promise has been settled (resolve or + * reject). Subsequent settles are no-ops. The active slot may remain + * occupied after a settle when the caller aborted mid-cycle — the queue + * waits for the consumer's resolveActive/rejectActive before pumping the + * next item. + */ + settled: boolean; +}; + +export type SendQueueOptions = { + maxDepth: number; + /** + * Called when a job moves from pending to active. The consumer drives + * the underlying work and must eventually call `resolveActive` or + * `rejectActive` exactly once. + */ + start: (item: T) => void; +}; + +export type SendQueue = { + enqueue(item: T, signal?: AbortSignal): Promise; + /** Mark the active job complete with success and pump the next. */ + resolveActive(value: R): void; + /** Mark the active job complete with failure and pump the next. */ + rejectActive(reason: unknown): void; + /** Reject the active job (if any) and every pending job with `reason`. */ + drain(reason: unknown): void; + /** Current pending count (queued + active). */ + readonly depth: number; +}; + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("aborted", "AbortError"); +} + +export function createSendQueue( + opts: SendQueueOptions, +): SendQueue { + const pending: Job[] = []; + let active: Job | null = null; + + function settle( + job: Job, + kind: "resolve" | "reject", + value: unknown, + ): void { + if (job.settled) return; + job.settled = true; + if (job.abortHandler !== undefined && job.signal !== undefined) { + job.signal.removeEventListener("abort", job.abortHandler); + } + if (kind === "resolve") { + // The queue's value type is checked at enqueue / resolveActive; the + // generic narrowing here is safe by construction. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic resolve value + job.resolve(value as R); + } else { + job.reject(value); + } + } + + function pump(): void { + while (active === null && pending.length > 0) { + const next = pending.shift(); + if (next === undefined) return; + if (next.signal?.aborted === true) { + settle(next, "reject", abortReason(next.signal)); + continue; + } + active = next; + opts.start(next.item); + return; + } + } + + function enqueue(item: T, signal?: AbortSignal): Promise { + if (signal?.aborted === true) { + return Promise.reject(abortReason(signal)); + } + + const depth = pending.length + (active !== null ? 1 : 0); + if (depth >= opts.maxDepth) { + throw new SendQueueFullError(opts.maxDepth); + } + + let resolve!: (value: R) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + const job: Job = { + item, + ...(signal !== undefined ? { signal } : {}), + resolve, + reject, + settled: false, + }; + + if (signal !== undefined) { + const handler = (): void => { + const reason = abortReason(signal); + if (active === job) { + // In flight: settle the caller now; the consumer will eventually + // call resolveActive/rejectActive which becomes a no-op and + // advances the queue. + settle(job, "reject", reason); + } else { + const idx = pending.indexOf(job); + if (idx >= 0) pending.splice(idx, 1); + settle(job, "reject", reason); + } + }; + signal.addEventListener("abort", handler, { once: true }); + job.abortHandler = handler; + } + + pending.push(job); + pump(); + return promise; + } + + function resolveActive(value: R): void { + if (active === null) return; + const job = active; + active = null; + settle(job, "resolve", value); + pump(); + } + + function rejectActive(reason: unknown): void { + if (active === null) return; + const job = active; + active = null; + settle(job, "reject", reason); + pump(); + } + + function drain(reason: unknown): void { + const drained: Job[] = []; + if (active !== null) { + drained.push(active); + active = null; + } + drained.push(...pending); + pending.length = 0; + for (const job of drained) { + settle(job, "reject", reason); + } + } + + return { + enqueue, + resolveActive, + rejectActive, + drain, + get depth() { + return pending.length + (active !== null ? 1 : 0); + }, + }; +} diff --git a/vendor/intx/agent/src/source.ts b/vendor/intx/agent/src/source.ts new file mode 100644 index 000000000..518f33a12 --- /dev/null +++ b/vendor/intx/agent/src/source.ts @@ -0,0 +1,179 @@ +// Inference source registry. +// +// The agent accepts an array of pre-configured inference sources and a +// `defaultSource` id at construction. The source whose `id` matches +// `defaultSource` becomes the active source — the same object reference +// is what the reactor's assembly holds and reads lazily at each +// inference call. +// +// `setSource` mutates that shared object in place so the next inference +// call observes the new credentials, model, and bound defaults. In-flight +// calls keep using the values they read at start-of-call (the reactor +// does not refetch mid-stream); the swap is therefore safe with respect +// to torn state. + +import { type } from "arktype"; + +import { + InferenceSource as InferenceSourceValidator, + applyInferenceSourceFields, + type InferenceSource, +} from "@intx/types/runtime"; + +export class InvalidInferenceSourceError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidInferenceSourceError"; + } +} + +export class SourceNotFoundError extends Error { + readonly id: string; + + constructor(id: string) { + super(`no source in sources[] has id ${id}`); + this.name = "SourceNotFoundError"; + this.id = id; + } +} + +export type SourceRegistry = { + /** + * The mutable active source. The same object reference is held by the + * reactor; mutating it (through `setSource`, `setSources`, + * `failOverToNextSource`, or `resetToPreferredSource`) is what swaps the + * source for subsequent inference calls. + */ + readonly active: InferenceSource; + /** Replace the active source's fields in place. */ + setSource(source: InferenceSource): void; + /** + * Replace the whole ordered list and position the active source at + * `defaultSource`. Used when the control plane pushes a re-resolved + * source list to a running agent. + */ + setSources(sources: InferenceSource[], defaultSource: string): void; + /** + * Fail over the active source to the next entry in priority order, in + * place. Returns false when the active source is already the last in the + * list — there is no further failover target. The ordered list and the + * cursor are private; only the registry mutates which source is active, + * so the single-active-source invariant the reactor relies on holds. + */ + failOverToNextSource(): boolean; + /** + * Reset the active source to the most-preferred (highest-priority) one, in + * place. The reactor calls this at the start of each inference cycle so a + * failover never permanently demotes the agent off its preferred source. + */ + resetToPreferredSource(): void; +}; + +function validateSources(sources: InferenceSource[]): InferenceSource[] { + if (sources.length === 0) { + throw new InvalidInferenceSourceError("sources[] must be non-empty"); + } + const validated: InferenceSource[] = []; + const seenIds = new Set(); + for (const [i, raw] of sources.entries()) { + const parsed = InferenceSourceValidator(raw); + if (parsed instanceof type.errors) { + throw new InvalidInferenceSourceError( + `sources[${String(i)}]: ${parsed.summary}`, + ); + } + if (seenIds.has(parsed.id)) { + throw new InvalidInferenceSourceError( + `sources[${String(i)}]: duplicate id ${parsed.id}`, + ); + } + seenIds.add(parsed.id); + validated.push(parsed); + } + return validated; +} + +export function createSourceRegistry(opts: { + sources: InferenceSource[]; + defaultSource: string; +}): SourceRegistry { + // The ordered list, the default index, and the active cursor are private + // to the registry. Only the registry mutates which source is active. + let list = validateSources(opts.sources); + let defaultIndex = indexOfDefault(list, opts.defaultSource); + let activeIndex = defaultIndex; + + const active: InferenceSource = { ...sourceAt(list, activeIndex) }; + + function setSource(source: InferenceSource): void { + const parsed = InferenceSourceValidator(source); + if (parsed instanceof type.errors) { + throw new InvalidInferenceSourceError(parsed.summary); + } + applyInferenceSourceFields(active, parsed); + // A hot-swap is an explicit override of the active source, possibly to a + // source that is not in the list at all. Park the cursor at the default + // so the next per-cycle resetToPreferredSource is a no-op and the + // override survives — even when a failover had moved the cursor off the + // default before the swap. + activeIndex = defaultIndex; + } + + function setSources(sources: InferenceSource[], defaultSource: string): void { + const validated = validateSources(sources); + const index = indexOfDefault(validated, defaultSource); + list = validated; + defaultIndex = index; + activeIndex = index; + applyInferenceSourceFields(active, sourceAt(list, activeIndex)); + } + + function failOverToNextSource(): boolean { + if (activeIndex >= list.length - 1) return false; + activeIndex += 1; + applyInferenceSourceFields(active, sourceAt(list, activeIndex)); + return true; + } + + function resetToPreferredSource(): void { + // Only undo a failover that actually moved the cursor. When the active + // source is already the preferred one, leave `active` untouched — a + // caller may have hot-swapped it via setSource (e.g. a director rotating + // the model), and that override must survive the per-cycle reset. + if (activeIndex === defaultIndex) return; + activeIndex = defaultIndex; + applyInferenceSourceFields(active, sourceAt(list, activeIndex)); + } + + return { + active, + setSource, + setSources, + failOverToNextSource, + resetToPreferredSource, + }; +} + +function indexOfDefault( + list: InferenceSource[], + defaultSource: string, +): number { + const match = list.find((s) => s.id === defaultSource); + if (match === undefined) { + throw new SourceNotFoundError(defaultSource); + } + return list.indexOf(match); +} + +function sourceAt(list: InferenceSource[], index: number): InferenceSource { + const source = list[index]; + if (source === undefined) { + // Unreachable: callers only ever pass an in-range index. The guard + // satisfies noUncheckedIndexedAccess without a non-null assertion and + // fails loud if that invariant is ever broken. + throw new InvalidInferenceSourceError( + `no source at index ${String(index)}`, + ); + } + return source; +} diff --git a/vendor/intx/agent/src/stream.ts b/vendor/intx/agent/src/stream.ts new file mode 100644 index 000000000..73fb27d58 --- /dev/null +++ b/vendor/intx/agent/src/stream.ts @@ -0,0 +1,142 @@ +// Bounded per-consumer fan-out for the agent's reactor event stream. +// +// Each call to `agent.stream()` creates a fresh `StreamConsumer`. The +// agent feeds every reactor event to every consumer; consumers buffer +// independently. If a consumer falls more than `maxBuffer` events behind +// it is poisoned with `StreamBackpressureError` and its iterator throws +// on the next read — the consumer is removed but other consumers keep +// running. +// +// Loud failure matches the defensive-coding rule: silently dropping +// events would hide consumer bugs, and unbounded buffering would let a +// stalled consumer balloon the agent's memory. The cap is configurable +// via `streamBufferMax` on `BaseEnv`. + +import type { ReactorEmittedEvent } from "@intx/inference"; + +export class StreamBackpressureError extends Error { + readonly maxBuffer: number; + + constructor(maxBuffer: number) { + super(`stream consumer fell more than ${String(maxBuffer)} events behind`); + this.name = "StreamBackpressureError"; + this.maxBuffer = maxBuffer; + } +} + +type Waiter = { + resolve: (value: IteratorResult) => void; + reject: (reason: unknown) => void; +}; + +export type StreamConsumer = { + /** Deliver an event to this consumer's buffer. */ + push(event: ReactorEmittedEvent): void; + /** Cleanly terminate the iterator with `done: true`. */ + close(): void; + /** True once close() or an overflow has poisoned the consumer. */ + readonly closed: boolean; + /** Iterator handed back to the caller of `stream()`. */ + iterator(): AsyncIterableIterator; +}; + +export function createStreamConsumer(maxBuffer: number): StreamConsumer { + if (maxBuffer < 1) { + throw new Error(`streamBufferMax must be >= 1, got ${String(maxBuffer)}`); + } + + const buffer: ReactorEmittedEvent[] = []; + const waiters: Waiter[] = []; + let overflow: StreamBackpressureError | undefined; + let done = false; + + function settleOverflowedWaiters(err: StreamBackpressureError): void { + while (waiters.length > 0) { + const w = waiters.shift(); + if (w === undefined) return; + w.reject(err); + } + } + + function settleDoneWaiters(): void { + while (waiters.length > 0) { + const w = waiters.shift(); + if (w === undefined) return; + w.resolve({ value: undefined, done: true }); + } + } + + function push(event: ReactorEmittedEvent): void { + if (done || overflow !== undefined) return; + + if (waiters.length > 0) { + const w = waiters.shift(); + if (w === undefined) return; + w.resolve({ value: event, done: false }); + return; + } + + if (buffer.length >= maxBuffer) { + overflow = new StreamBackpressureError(maxBuffer); + settleOverflowedWaiters(overflow); + return; + } + + buffer.push(event); + } + + function close(): void { + if (done) return; + done = true; + settleDoneWaiters(); + } + + function nextResult(): Promise> { + if (overflow !== undefined) { + // Drain any buffered events before throwing so the caller sees + // every event up to the overflow point. + if (buffer.length > 0) { + const ev = buffer.shift(); + if (ev !== undefined) { + return Promise.resolve({ value: ev, done: false }); + } + } + return Promise.reject(overflow); + } + if (buffer.length > 0) { + const ev = buffer.shift(); + if (ev !== undefined) { + return Promise.resolve({ value: ev, done: false }); + } + } + if (done) { + return Promise.resolve({ value: undefined, done: true }); + } + return new Promise((resolve, reject) => { + waiters.push({ resolve, reject }); + }); + } + + function iterator(): AsyncIterableIterator { + const it: AsyncIterableIterator = { + next: nextResult, + async return() { + close(); + return { value: undefined, done: true }; + }, + [Symbol.asyncIterator]() { + return it; + }, + }; + return it; + } + + return { + push, + close, + get closed() { + return done || overflow !== undefined; + }, + iterator, + }; +} diff --git a/vendor/intx/agent/src/testing/audit-noop.ts b/vendor/intx/agent/src/testing/audit-noop.ts new file mode 100644 index 000000000..612d682a7 --- /dev/null +++ b/vendor/intx/agent/src/testing/audit-noop.ts @@ -0,0 +1,29 @@ +// No-op AuditStore for tests and examples. +// +// Returns immediately for every commit; returns empty arrays for every +// load. Useful when the agent is exercised in tests that do not assert +// audit content, or in examples whose purpose is the agent surface +// rather than the audit ledger. Production callers must supply a real +// audit store. + +import type { AuditRecord, ErrorRecord } from "@intx/types/audit"; +import type { AuditStore } from "@intx/types/runtime"; + +/** + * Construct a no-op AuditStore. Each call returns a fresh object so + * tests that introspect the store identity (e.g. asserting two agents + * received different stores) can do so. + */ +export function noopAuditStore(): AuditStore { + return { + async commitAudit(_records: AuditRecord[]): Promise { + // No-op. + }, + async commitErrors(_errors: ErrorRecord[]): Promise { + // No-op. + }, + async loadAudit(_sessionId: string): Promise { + return []; + }, + }; +} diff --git a/vendor/intx/agent/src/testing/authorize-allow.ts b/vendor/intx/agent/src/testing/authorize-allow.ts new file mode 100644 index 000000000..a381f4bc6 --- /dev/null +++ b/vendor/intx/agent/src/testing/authorize-allow.ts @@ -0,0 +1,22 @@ +// Permissive AuthorizeFn for tests and examples. +// +// Returns { effect: "allow" } for every call. Useful when the agent is +// exercised without grants -- the test cares about the agent surface +// rather than the authz decision. Production callers must supply a real +// authorize function tied to actual policy. + +import type { AuthorizeFn } from "../env"; + +/** + * Construct a permissive AuthorizeFn that allows every call. The + * returned function ignores its arguments (including the third + * per-call context parameter) and returns the same shape the + * production authz extension expects. + */ +export function permissiveAuthorize(): AuthorizeFn { + return async (_resource, _action, _context) => ({ + effect: "allow", + matchingGrants: [], + resolvedBy: null, + }); +} diff --git a/vendor/intx/agent/src/testing/index.ts b/vendor/intx/agent/src/testing/index.ts new file mode 100644 index 000000000..1819854b4 --- /dev/null +++ b/vendor/intx/agent/src/testing/index.ts @@ -0,0 +1,18 @@ +// @intx/agent/testing -- no-op implementations of the env contract's +// required fields, for tests and examples. +// +// The exports here silently permit every authz decision and discard +// every audit record. They exist so test fixtures, the in-tree +// examples, and short-lived demos can satisfy `BaseEnv` without +// bringing in a real audit store or policy engine. Production +// deployments replace these with a real `AuditStore` (durably +// recording audit and error events) and a real `authorize` callback +// (gating tool calls per the deployment's policy). Importing from +// this subpath in production silently disables auditing and allows +// every tool call, which is almost never what a production caller +// actually wants -- treat the subpath the same way you would treat a +// hard-coded `() => true` permission check elsewhere in the +// codebase. + +export { noopAuditStore } from "./audit-noop"; +export { permissiveAuthorize } from "./authorize-allow"; diff --git a/vendor/intx/agent/src/tool.ts b/vendor/intx/agent/src/tool.ts new file mode 100644 index 000000000..d4467b7c2 --- /dev/null +++ b/vendor/intx/agent/src/tool.ts @@ -0,0 +1,433 @@ +// Tool registration and dispatch. +// +// Two registration shapes are supported for per-tool authoring: +// +// `tool({ definition, handler })` - handler receives the full +// ToolCall and returns the full +// ToolResult. Use when the +// handler needs the callId or +// wants to set isError/detail/ +// pendingMarker. +// +// `stringTool({ definition, handler })` - sugar for the common case of +// "compute a string from the +// parsed arguments." The callId +// is filled in from the +// surrounding ToolCall, and +// isError is false unless the +// handler throws. +// +// `createToolRunner(tools)` builds a `ToolRunner` that dispatches by tool +// name. Per the ToolRunner contract (packages/types/src/runtime.ts), `run` +// must not throw -- unknown tool names and handler exceptions are surfaced +// as `ToolResult` with `isError: true` so the model sees them and can +// recover. +// +// `defineTool({ id, requires?, factory })` is the env-DI factory shape. +// It produces an +// `AnnotatedToolFactory` whose `factory(env)` returns a `ToolBundle` +// exposing a set of tool definitions, a dispatcher, and an optional +// disposer. Bundle-style (rather than per-tool) factory shapes match +// the existing posix-tools and mail-tools ergonomics; a package that +// wants per-tool granularity wraps each tool in its own single- +// definition bundle. + +import type { GrantEffect } from "@intx/types"; +import type { + ToolCall, + ToolDefinition, + ToolResult, + ToolRunner, +} from "@intx/types/runtime"; + +import type { BaseEnv } from "./env"; +import { validateNamespacedId } from "./namespace"; + +export type ToolHandler = ( + call: ToolCall, + signal: AbortSignal, +) => Promise; + +export type StringToolHandler = ( + args: Record, + signal: AbortSignal, +) => Promise; + +export type AgentTool = + | { kind: "full"; definition: ToolDefinition; handler: ToolHandler } + | { + kind: "string"; + definition: ToolDefinition; + handler: StringToolHandler; + }; + +export function tool(args: { + definition: ToolDefinition; + handler: ToolHandler; +}): AgentTool { + return { kind: "full", definition: args.definition, handler: args.handler }; +} + +export function stringTool(args: { + definition: ToolDefinition; + handler: StringToolHandler; +}): AgentTool { + return { + kind: "string", + definition: args.definition, + handler: args.handler, + }; +} + +/** + * Adapt a pre-built ToolRunner (e.g. the one returned by + * `createPosixTools`) into a list of AgentTools that can be passed to + * `createAgent({ tools })`. Each definition becomes a full-handler + * AgentTool that delegates to the runner's `run`. + * + * Use this when integrating tool packages whose public surface is a + * single ToolRunner rather than individual handlers. + */ +export function fromToolRunner(runner: { + readonly definitions: readonly ToolDefinition[]; + run: ToolRunner["run"]; +}): AgentTool[] { + return runner.definitions.map((definition) => ({ + kind: "full", + definition, + handler: (call, signal) => runner.run(call, signal), + })); +} + +export class DuplicateToolError extends Error { + readonly toolName: string; + + constructor(toolName: string) { + super(`duplicate tool name: ${toolName}`); + this.name = "DuplicateToolError"; + this.toolName = toolName; + } +} + +export type AgentToolRunner = ToolRunner & { + readonly definitions: readonly ToolDefinition[]; +}; + +/** + * A bundle of tools constructed by an `AnnotatedToolFactory`. Exposes + * the set of tool definitions the model sees, a single dispatcher + * (`run`), and an optional disposer the caller invokes after the agent + * closes. + * + * Disposer ownership lives with the caller -- the env is the agent's + * dependency contract; the caller owns the lifetime of what it puts in + * env. The agent does not invoke `dispose` itself. + */ +export interface ToolBundle { + readonly definitions: readonly ToolDefinition[]; + run(call: ToolCall, signal: AbortSignal): Promise; + dispose?(): Promise; +} + +/** + * Factory function shape -- consumes an env extending `BaseEnv` and + * produces a `ToolBundle`. The factory is invoked once per agent + * instantiation. + */ +export type ToolFactory = ( + env: EnvReq, +) => ToolBundle; + +/** + * Static, per-definition declaration a tool factory carries so callers + * (e.g. the deploy-time capability walk) can enumerate the tool names a + * factory contributes WITHOUT instantiating it. `approval` marks a tool + * as requiring per-invocation approval; it is a deliberate subset of + * GrantEffect (only "ask" is expressible here — a declaration can request + * a gate, never a pre-deny). + */ +export interface ToolDeclaration { + readonly name: string; + readonly approval?: "ask"; +} + +/** + * Map a tool's static approval mark to the `GrantEffect` floor its + * `tool:` grant carries: an `ask`-marked tool floors at `ask` (its + * invocation must clear an approval gate), every other tool floors at + * `allow`. + * + * This is the single canonical derivation of a tool's authorization floor + * from its declaration. Both the deploy-time capability walk (hub-side) + * and the per-step tool authorization (sidecar-side) route through here so + * a pinned tool loaded in the child derives the SAME floor the walk would + * have derived from an inline declaration. A divergence between the two + * sites would let a pinned `ask` tool authorize as `allow` (or vice + * versa), so the mapping lives in exactly one place. + */ +export function toolApprovalEffect( + declaration: Pick, +): GrantEffect { + return declaration.approval === "ask" ? "ask" : "allow"; +} + +/** + * Runtime metadata attached to a `ToolFactory` by `defineTool`. `id` is + * package-namespaced; `requires` enumerates env keys the factory touches + * beyond `BaseEnv`'s six core fields; `definitions` statically declares + * the tool names the factory contributes so callers can enumerate them + * without instantiating the factory. + */ +export interface ToolFactoryMeta { + readonly id: string; + readonly requires: readonly string[]; + readonly definitions: readonly ToolDeclaration[]; +} + +/** + * A tool factory carrying its runtime metadata. `defineTool` is the only + * sanctioned construction path. + * + * The intersection `ToolFactory & ToolFactoryMeta` is the + * type-level surface; at runtime the meta fields are attached to the + * factory function via `Object.assign`. + */ +export type AnnotatedToolFactory = + ToolFactory & ToolFactoryMeta; + +/** + * Define a tool bundle factory. + * + * - `id` must be package-namespaced ("@vendor/pkg/name" or + * "pkg/name"). Bare ids throw `Error` at definition time. + * - `requires` enumerates the env keys this factory touches beyond + * `BaseEnv`'s six core fields. The runtime `validateEnv` checks + * presence; the factory itself may also fail loud at construction + * if the env contents are structurally wrong. + * - `definitions` statically declares the tool names this factory + * contributes so callers (e.g. the deploy-time capability walk) can + * enumerate them without instantiating the factory. + * - `factory(env)` returns a `ToolBundle`. Invoked once per agent + * instantiation; the bundle's lifetime is tied to that agent. + * + * The returned object is the same callable as the supplied `factory` + * with `id`, a frozen `requires` array, and a frozen `definitions` + * array attached. + */ +export function defineTool(opts: { + id: string; + requires?: readonly string[]; + definitions: readonly ToolDeclaration[]; + factory: ToolFactory; +}): AnnotatedToolFactory { + validateNamespacedId(opts.id); + const requires = Object.freeze([ + ...(opts.requires ?? []), + ]) as readonly string[]; + const definitions = Object.freeze([ + ...opts.definitions, + ]) as readonly ToolDeclaration[]; + // Wrap the caller's factory rather than mutating it. A caller that + // shares a factory function across multiple `defineTool` calls + // (e.g. registering the same constructor under two ids in different + // bundles) needs each `AnnotatedToolFactory` to be a distinct + // identity with its own metadata; a direct `Object.assign` on + // `opts.factory` would let the second call silently overwrite the + // first's annotations. + const wrapped: ToolFactory = (env) => opts.factory(env); + return Object.assign(wrapped, { + id: opts.id, + requires, + definitions, + }); +} + +/** + * A plugin contributes capabilities (extra tools, middleware, anything + * a host plugin protocol defines) without producing a `ToolBundle` + * itself. Plugins are first-class entries in an `interchange.tools` + * module alongside `AnnotatedToolFactory` exports. + * + * The shape the factory returns is host-defined: tool packages that + * accept plugins read `env.plugins` and dispatch by structural shape + * (or by an explicit kind marker the host agrees on). The agent + * runtime does not interpret plugin shapes; it only delivers them. + * + * The marker is a `Symbol.for`-registered key (PLUGIN_MARKER) so a + * duck-typed loader can separate plugins from `AnnotatedToolFactory`s + * without re-running `defineTool`/`definePlugin` against each export. + * Using a registered symbol (rather than a string key like `_plugin`) + * prevents third-party objects from accidentally satisfying the + * marker check by happening to have a property of the same name. + */ +export type PluginFactory = ( + env: EnvReq, +) => Result; + +/** + * Registered symbol that tags `AnnotatedPluginFactory` values. Exported + * so consumers that need to introspect plugin factories directly can + * read the marker without re-registering the key. + */ +export const PLUGIN_MARKER: unique symbol = Symbol.for("@intx/agent.plugin"); + +export interface AnnotatedPluginMeta { + readonly id: string; + readonly requires: readonly string[]; + /** + * Static declaration of the tool names this plugin contributes at + * runtime, so a caller can enumerate the plugin's tool grant surface + * WITHOUT instantiating it (which for a plugin like LSP would start a + * language-server subprocess). A plugin adds its tools indirectly -- it + * hands a host-defined shape to the tool package that consumes + * `env.plugins`, which then registers the plugin's tools under its own + * bundle -- so the plugin's contributed tool names are otherwise + * invisible until run time. The deploy-time capability walk reads this + * field to authorize a plugin-contributed tool the same way it + * authorizes a factory-declared tool. Empty when the plugin contributes + * no standalone tool (middleware-only plugins). + */ + readonly definitions: readonly ToolDeclaration[]; + readonly [PLUGIN_MARKER]: true; +} + +export type AnnotatedPluginFactory< + EnvReq extends BaseEnv = BaseEnv, + Result = unknown, +> = PluginFactory & AnnotatedPluginMeta; + +/** + * Constant on every plugin instance returned by `definePlugin`. Hosts + * that need to distinguish plugin instances from arbitrary objects + * received via `env.plugins` check this field before duck-typing on + * shape. The string value is the operative form of the contract — the + * value-side marker exists because the factory-side symbol marker + * (PLUGIN_MARKER above) is only visible to code that imported it, + * while the kind string travels through pure-JSON inspection too. + */ +export const TOOL_PLUGIN_KIND = "tool-plugin" as const; + +/** Type-level form of the kind marker. */ +export type ToolPluginKind = typeof TOOL_PLUGIN_KIND; + +/** + * Predicate hosts use to confirm a value off `env.plugins` was minted + * by `definePlugin` rather than happening to satisfy a shape-based + * check. Returns true iff the value is an object carrying the + * literal `kind: "tool-plugin"` marker. + */ +export function isToolPluginInstance( + value: unknown, +): value is Record & { kind: ToolPluginKind } { + if (value === null || typeof value !== "object") return false; + if (!("kind" in value)) return false; + return (value as { kind: unknown }).kind === TOOL_PLUGIN_KIND; +} + +/** + * Define a plugin factory. The plugin's `Result` is host-defined and + * surfaces in `env.plugins` for the host-side tool factories that + * consume it. The returned instance is tagged with + * `kind: "tool-plugin"` so hosts can identify plugin instances + * structurally without falling back to duck-typing on the result's + * own shape. + * + * `id` must be package-namespaced — same rule as `defineTool` — so + * audit provenance threads through plugins as cleanly as through + * tools. + */ +export function definePlugin< + Result extends object, + EnvReq extends BaseEnv = BaseEnv, +>(opts: { + id: string; + requires?: readonly string[]; + /** + * Static declaration of the tool names this plugin contributes at run + * time. Omit for a middleware-only plugin that adds no standalone tool. + * See `AnnotatedPluginMeta.definitions`. + */ + definitions?: readonly ToolDeclaration[]; + factory: PluginFactory; +}): AnnotatedPluginFactory { + validateNamespacedId(opts.id); + const requires = Object.freeze([ + ...(opts.requires ?? []), + ]) as readonly string[]; + const definitions = Object.freeze([ + ...(opts.definitions ?? []), + ]) as readonly ToolDeclaration[]; + const wrapped: PluginFactory = ( + env, + ) => { + const instance = opts.factory(env); + // Re-stamping the marker is harmless if the factory chose to set + // it itself; otherwise we add it. Either way the returned value + // carries the contract. + return Object.assign(instance, { kind: TOOL_PLUGIN_KIND }); + }; + return Object.assign(wrapped, { + id: opts.id, + requires, + definitions, + [PLUGIN_MARKER]: true as const, + }); +} + +/** Type predicate distinguishing plugin factories from tool factories. */ +export function isAnnotatedPluginFactory( + value: unknown, +): value is AnnotatedPluginFactory { + if (typeof value !== "function") return false; + if (!(PLUGIN_MARKER in value)) return false; + // `PLUGIN_MARKER in value` narrows `value` to include the symbol + // key, so the index access below is type-safe without a cast. + return value[PLUGIN_MARKER] === true; +} + +/** + * Build a `ToolRunner` that dispatches by tool name. Throws + * `DuplicateToolError` at construction if any two tools share a name. + * + * At call time, unknown tool names and exceptions from handlers are + * converted to `ToolResult { isError: true }` so the contract on + * `ToolRunner.run` ("must not throw") is upheld. + */ +export function createToolRunner(tools: AgentTool[]): AgentToolRunner { + const byName = new Map(); + for (const t of tools) { + if (byName.has(t.definition.name)) { + throw new DuplicateToolError(t.definition.name); + } + byName.set(t.definition.name, t); + } + + const definitions: readonly ToolDefinition[] = tools.map((t) => t.definition); + + return { + definitions, + async run(call, signal): Promise { + const found = byName.get(call.name); + if (found === undefined) { + return { + callId: call.id, + content: `unknown tool: ${call.name}`, + isError: true, + }; + } + try { + if (found.kind === "full") { + return await found.handler(call, signal); + } + const text = await found.handler(call.arguments, signal); + return { callId: call.id, content: text }; + } catch (err) { + return { + callId: call.id, + content: err instanceof Error ? err.message : String(err), + isError: true, + }; + } + }, + }; +} diff --git a/vendor/intx/agent/tsconfig.json b/vendor/intx/agent/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/agent/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/inference/README.md b/vendor/intx/inference/README.md new file mode 100644 index 000000000..f7bc4f25c --- /dev/null +++ b/vendor/intx/inference/README.md @@ -0,0 +1,46 @@ +# @intx/inference + +Provider-agnostic inference runtime. Adapters for Anthropic, +OpenAI-compatible relays (including OpenCode Zen), and Google +GenAI; SSE parsing; retry and error classification; message +transforms; and the reactor harness that drives a turn from +prompt to settled tool calls. + +Consumed by `@intx/harness`, which composes the reactor with tool +runners, directors, and runtime capabilities. + +```ts +import { createReactorAssembly, createDefaultDirector } from "@intx/inference"; + +const director = createDefaultDirector( + systemPrompt, + toolDefinitions, // ToolDefinition[] — the tool surface advertised to the model + policy, +); + +const assembly = createReactorAssembly({ + sessionId, + director, + source, // InferenceSource: id, provider, model, apiKey, baseURL + toolRunner, // ToolRunner — dispatches the tool calls the director emits + contextStore, + onEvent: (event) => { + // event: ReactorEmittedEvent — persist or forward + }, +}); + +assembly.reactor.start(); +``` + +Provider selection is driven by `source.provider`; the assembly +resolves the matching adapter internally. `ReactorAssemblyConfig` +in `src/assembly.ts` documents the optional fields (`authorize`, +`auditStore`, transforms, compactors, correlation, gate +timeouts). + +The package is the lower half of the agent stack: it knows how to +talk to model providers, how to drive a multi-turn exchange to +completion, and how to compose extensions (gates, audit, +correlation, authz) into the reactor pipeline. The higher-level +agent surface — config validation, tool runners, deploy trees, +runtime capabilities — lives in `@intx/harness`. diff --git a/vendor/intx/inference/VENDORED-FROM b/vendor/intx/inference/VENDORED-FROM new file mode 100644 index 000000000..df126e855 --- /dev/null +++ b/vendor/intx/inference/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/inference) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. providers/google-genai-files.ts builds its upload body as `new Uint8Array(bytes)`: TS 6's lib.dom `BodyInit` rejects `Uint8Array` and workbench compiles this source under DOM-lib packages (upstream compiles ESNext-only under TS 5.9). diff --git a/vendor/intx/inference/package.json b/vendor/intx/inference/package.json new file mode 100644 index 000000000..683c817c7 --- /dev/null +++ b/vendor/intx/inference/package.json @@ -0,0 +1,38 @@ +{ + "name": "@intx/inference", + "description": "Provider-agnostic inference runtime with Anthropic, OpenAI, and Google GenAI adapters", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./providers": { + "types": "./src/providers/index.ts", + "default": "./src/providers/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/log": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/inference/src/actions.ts b/vendor/intx/inference/src/actions.ts new file mode 100644 index 000000000..0bb53691c --- /dev/null +++ b/vendor/intx/inference/src/actions.ts @@ -0,0 +1,245 @@ +// Action types and validation for the agent reactor. +// +// The reactor validates the set of actions returned by the director before +// executing any of them. Invalid combinations produce a reactor.error rather +// than partial execution — the reactor does not guess intent. +// +// Validation rules (INFERENCE.md § Action Validation): +// - At most one `infer` action. +// - At most one `done` action. +// - At most one `reply` action. +// - `infer` + `done` together is invalid. +// - `reply` + `infer` together is invalid. +// - `reply` + `execute_tools` together is invalid. +// - `reply` + `done` together is invalid. +// - `reply` + `suspend` together is invalid. +// - `wait` + `infer` together is invalid. +// - `wait` + `execute_tools` together is invalid. +// - `wait` + `suspend` together is invalid. +// - `wait` + `reply` together is invalid. +// - `wait` + `done` together is invalid. +// - `suspend` cannot appear alongside `infer` or `execute_tools`. +// - `fork` is composable — may appear alongside any other action. +// - At most one `checkpoint` action; composable with any other action. +// - At most one `wait` action. +// - Multiple `execute_tools` are merged into a single parallel batch. +// - `emit` is always valid and composable. +// - At most one `compact` action; composable with `checkpoint`, `emit`, and +// `fork`. Not composable with `infer`, `execute_tools`, `reply`, `suspend`, +// `wait`, or `done`. Context-overflow recovery runs compaction in its own +// cycle and re-infers on the next director invocation. + +import type { ReactorAction, ToolCall } from "@intx/types/runtime"; + +export type ValidationResult = + | { ok: true; normalized: ReactorAction[] } + | { ok: false; error: string }; + +/** + * Validate and normalize a set of actions returned by the director. + * + * On success, returns the normalized action list with multiple `execute_tools` + * collapsed into a single batched action. On failure, returns a diagnostic + * error string. + */ +export function validateActions( + actions: ReactorAction | ReactorAction[], +): ValidationResult { + const list = Array.isArray(actions) ? actions : [actions]; + + // An empty action list means "no-op, keep waiting for the next event." + // This is valid — the reactor loop continues to waitForEvent(). + if (list.length === 0) { + return { ok: true, normalized: [] }; + } + + const inferActions = list.filter((a) => a.type === "infer"); + const doneActions = list.filter((a) => a.type === "done"); + const replyActions = list.filter((a) => a.type === "reply"); + const suspendActions = list.filter((a) => a.type === "suspend"); + const executeActions = list.filter( + (a): a is Extract => + a.type === "execute_tools", + ); + const waitActions = list.filter((a) => a.type === "wait"); + const forkActions = list.filter((a) => a.type === "fork"); + const emitActions = list.filter((a) => a.type === "emit"); + const checkpointActions = list.filter((a) => a.type === "checkpoint"); + const compactActions = list.filter((a) => a.type === "compact"); + + if (checkpointActions.length > 1) { + return { + ok: false, + error: "Multiple checkpoint actions are not allowed", + }; + } + + if (inferActions.length > 1) { + return { ok: false, error: "Multiple infer actions are not allowed" }; + } + + if (doneActions.length > 1) { + return { ok: false, error: "Multiple done actions are not allowed" }; + } + + if (inferActions.length > 0 && doneActions.length > 0) { + return { ok: false, error: "infer and done cannot appear together" }; + } + + if (replyActions.length > 1) { + return { ok: false, error: "Multiple reply actions are not allowed" }; + } + + if (replyActions.length > 0 && inferActions.length > 0) { + return { ok: false, error: "reply and infer cannot appear together" }; + } + + if (replyActions.length > 0 && executeActions.length > 0) { + return { + ok: false, + error: "reply and execute_tools cannot appear together", + }; + } + + if (replyActions.length > 0 && doneActions.length > 0) { + return { ok: false, error: "reply and done cannot appear together" }; + } + + if (replyActions.length > 0 && suspendActions.length > 0) { + return { ok: false, error: "reply and suspend cannot appear together" }; + } + + if (suspendActions.length > 0) { + if (inferActions.length > 0) { + return { + ok: false, + error: "suspend cannot appear alongside infer", + }; + } + if (executeActions.length > 0) { + return { + ok: false, + error: "suspend cannot appear alongside execute_tools", + }; + } + if (suspendActions.length > 1) { + return { ok: false, error: "Multiple suspend actions are not allowed" }; + } + } + + if (waitActions.length > 1) { + return { ok: false, error: "Multiple wait actions are not allowed" }; + } + + if (waitActions.length > 0) { + if (inferActions.length > 0) { + return { ok: false, error: "wait and infer cannot appear together" }; + } + if (executeActions.length > 0) { + return { + ok: false, + error: "wait and execute_tools cannot appear together", + }; + } + if (suspendActions.length > 0) { + return { ok: false, error: "wait and suspend cannot appear together" }; + } + if (replyActions.length > 0) { + return { ok: false, error: "wait and reply cannot appear together" }; + } + if (doneActions.length > 0) { + return { ok: false, error: "wait and done cannot appear together" }; + } + } + + if (compactActions.length > 1) { + return { ok: false, error: "Multiple compact actions are not allowed" }; + } + + if (compactActions.length > 0) { + if (inferActions.length > 0) { + return { ok: false, error: "compact and infer cannot appear together" }; + } + if (executeActions.length > 0) { + return { + ok: false, + error: "compact and execute_tools cannot appear together", + }; + } + if (replyActions.length > 0) { + return { ok: false, error: "compact and reply cannot appear together" }; + } + if (suspendActions.length > 0) { + return { ok: false, error: "compact and suspend cannot appear together" }; + } + if (waitActions.length > 0) { + return { ok: false, error: "compact and wait cannot appear together" }; + } + if (doneActions.length > 0) { + return { ok: false, error: "compact and done cannot appear together" }; + } + } + + // Verify fork actions have unique IDs. + const forkIds = forkActions.map( + (a) => (a as Extract).forkId, + ); + const uniqueForkIds = new Set(forkIds); + if (forkIds.length !== uniqueForkIds.size) { + return { ok: false, error: "Duplicate fork IDs in action list" }; + } + + // Build normalized list: collapse execute_tools into one parallel batch. + const normalized: ReactorAction[] = []; + + for (const a of checkpointActions) { + normalized.push(a); + } + + for (const a of emitActions) { + normalized.push(a); + } + + for (const a of forkActions) { + normalized.push(a); + } + + for (const a of compactActions) { + normalized.push(a); + } + + if (executeActions.length > 0) { + const merged: ToolCall[] = executeActions.flatMap((a) => a.calls); + const allAddToHistory = executeActions.every( + (a) => a.addToHistory !== false, + ); + normalized.push({ + type: "execute_tools", + calls: merged, + parallel: true, + ...(!allAddToHistory ? { addToHistory: false } : {}), + }); + } + + for (const a of replyActions) { + normalized.push(a); + } + + for (const a of inferActions) { + normalized.push(a); + } + + for (const a of suspendActions) { + normalized.push(a); + } + + for (const a of waitActions) { + normalized.push(a); + } + + for (const a of doneActions) { + normalized.push(a); + } + + return { ok: true, normalized }; +} diff --git a/vendor/intx/inference/src/adapter.ts b/vendor/intx/inference/src/adapter.ts new file mode 100644 index 000000000..04c9efc52 --- /dev/null +++ b/vendor/intx/inference/src/adapter.ts @@ -0,0 +1,139 @@ +import type { + ConversationTurn, + InferenceEvent, + InferenceOptions, + LastCycleSource, +} from "@intx/types/runtime"; + +// The request shape the harness passes to fetch. +export type BuiltRequest = { + url: string; + headers: Record; + body: string; +}; + +// A request builder takes the internal message format and produces a +// provider-specific HTTP request. Pure function — no state, no side effects. +export type RequestBuilder = ( + messages: ConversationTurn[], + model: string, + options: InferenceOptions, +) => BuiltRequest; + +// A response parser converts one SSE data payload string into zero or more +// internal inference events. May close over per-request state (e.g., for +// correlating content block indices with tool call IDs). Each adapter +// instance is created per inference call, so state does not leak across +// requests. +// +// The parser may return an empty array for events it doesn't care about +// (e.g., Anthropic's ping events). It MAY throw `ProtocolMismatchError` +// when the upstream chunk violates the provider's protocol (malformed +// JSON, schema validation failure, out-of-order events); the harness's +// stream-error catch converts that into an `inference.error` with +// category `"protocol_mismatch"` via `classifyStreamError`. No other +// throw type is permitted, and adapter-returned `inference.error` or +// `inference.done` events are silently dropped — the harness owns +// emission of those terminal types and the only path through which +// 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. + +// Extracts a retry delay from provider-specific response headers on a 429. +// Returns milliseconds to wait, or undefined if no retry info is available. +export type RetryAfterExtractor = (headers: Headers) => number | undefined; + +// Extracts a pacing delay from response headers on ANY response (including +// success). Checks remaining rate limit capacity and returns how long to +// wait before the next request, or undefined if no pacing is needed. +export type PacingExtractor = (headers: Headers) => number | undefined; + +export type ProviderAdapter = { + buildRequest: RequestBuilder; + parseResponse: ResponseParser; + parseJSONResponse: JSONResponseParser; + extractRetryAfterMs?: RetryAfterExtractor; + extractPacingDelayMs?: PacingExtractor; +}; + +// Builds a fresh adapter for one inference call. Invoked per call so the +// returned adapter's per-request parser state never leaks across calls. +// +// `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, quirks?: unknown): ProviderAdapter; +}; + +/** + * Builds an adapter registry from a map of provider identifier to adapter + * factory. The registry closes over a private copy of the map, so callers + * cannot mutate the set after construction. The copy is a `Map`, whose + * lookups never consult `Object.prototype`, so an untrusted provider string + * (e.g. `"toString"`) cannot reach an inherited member and be invoked as a + * factory — it resolves to the loud `Unknown inference provider` error. + * + * `resolve` invokes the matching factory fresh on every call and never + * memoizes the adapter instance. That per-call freshness is load-bearing: + * the response parser holds per-request state, so a cached adapter would + * leak that state across inference calls and across failover attempts. + * + * @param factories - Map of provider identifier to adapter factory + * @returns A registry exposing membership and per-call resolution + */ +export function createAdapterRegistry( + factories: Readonly>, +): AdapterRegistry { + const byProvider = new Map(Object.entries(factories)); + + return { + has(provider: string): boolean { + return byProvider.has(provider); + }, + 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, quirks); + }, + }; +} diff --git a/vendor/intx/inference/src/assembly.ts b/vendor/intx/inference/src/assembly.ts new file mode 100644 index 000000000..8a3d26381 --- /dev/null +++ b/vendor/intx/inference/src/assembly.ts @@ -0,0 +1,271 @@ +// Reactor assembly helper. +// +// `createReactorAssembly` is the canonical way to construct a reactor when the +// caller wants the standard wiring: a default size-cap tool-result transform, +// authz as a before-tool extension, an audit collector that flushes at +// checkpoint and shutdown boundaries, and a `BlobReader` over the supplied +// context store. Future reactor consumers (the harness today; in-process agent +// runtimes tomorrow) should use this helper rather than calling `createReactor` +// directly so the wiring stays consistent across composition points. + +import { getLogger } from "@intx/log"; +import { + createBlobReader, + type BlobReader, + type AuditStore, + type BeforeToolExtension, + type Compactor, + type ContextStore, + type ContextTransform, + type InferenceSource, + type ReactorDirector, + type ToolDefinition, + type ToolResultTransform, + type ToolRunner, +} from "@intx/types/runtime"; + +import { createAuditCollector, type AuditCollector } from "./audit-collector"; +import { + createAuthzExtension, + type AuthzExtensionOptions, +} from "./authz-extension"; +import type { CorrelationValidator } from "./correlation"; +import type { Dependencies } from "./harness"; +import { + createReactor, + type Reactor, + type ReactorConfig, + type ReactorEmittedEvent, +} from "./reactor"; +import { createSizeCapTransform } from "./transforms"; + +const logger = getLogger(["interchange", "assembly"]); + +const DEFAULT_SIZE_CAP_MAX_CHARS = 10_000; + +/** + * Configuration for `createReactorAssembly`. Required fields mirror + * `ReactorConfig`. Optional fields toggle the composed extensions: the helper + * builds an authz before-tool extension when `authorize` is supplied, builds + * and wires an audit collector when `auditStore` is supplied, and always + * prepends a default size-cap tool-result transform. + * + * The helper does NOT wrap the supplied `contextStore`; callers that need + * additional behavior (the harness wraps for connector-thread state) layer + * that on themselves before passing the store in. + */ +export type ReactorAssemblyConfig = { + sessionId: string; + director: ReactorDirector; + source: InferenceSource; + /** + * Fail over `source` to the next entry in the priority-ordered source + * list, in place, returning false at the end of the list. Omit for a + * single-source reactor with no failover target. + */ + failOverToNextSource?: () => boolean; + /** Reset `source` to the most-preferred source, in place. */ + resetToPreferredSource?: () => void; + toolRunner: ToolRunner; + contextStore: ContextStore; + 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[]; + contextTransforms?: ContextTransform[]; + compactors?: Record; + sizeCapMaxChars?: number; + + afterCheckpoint?: () => Promise; + onShutdown?: () => Promise; + + deps: Dependencies; + correlationValidator?: CorrelationValidator; + inferenceRunner?: ReactorConfig["inferenceRunner"]; + gateTimeout?: number; + shutdownTimeoutMs?: number; + doomLoopThreshold?: number | false; +}; + +/** + * Output of `createReactorAssembly`. The `reactor` is started by the caller as + * usual. `blobReader` is exposed so the caller can pass it to tool factories + * that resolve `tool-output:///{callId}` URIs against the same context store + * the reactor commits to. `auditCollector` is `undefined` when no `auditStore` + * was supplied. + */ +export type ReactorAssembly = { + reactor: Reactor; + blobReader: BlobReader; + auditCollector: AuditCollector | undefined; +}; + +/** + * Build the standard reactor wiring. This is the canonical reactor-assembly + * path: any consumer that needs the default size-cap transform, authz, audit + * collection, and blob reader should call this helper instead of constructing + * a `ReactorConfig` by hand. Direct `createReactor` use is reserved for + * reactor-internal tests and any future consumer that genuinely needs a + * different composition. + */ +export function createReactorAssembly( + config: ReactorAssemblyConfig, +): ReactorAssembly { + const { + sessionId, + director, + source, + failOverToNextSource, + resetToPreferredSource, + toolRunner, + contextStore, + onEvent, + authorize, + toolDefinitions, + auditStore, + beforeToolExtensions: callerBeforeToolExtensions, + toolResultTransforms: callerToolResultTransforms, + contextTransforms, + compactors, + sizeCapMaxChars, + afterCheckpoint: callerAfterCheckpoint, + onShutdown: callerOnShutdown, + deps, + correlationValidator, + inferenceRunner, + gateTimeout, + shutdownTimeoutMs, + doomLoopThreshold, + } = config; + + // Audit collector is created up-front so the authz extension can route its + // decisions through `onDecision`. When no auditStore is supplied, no + // collector is created and authz runs without decision recording. + const auditCollector: AuditCollector | undefined = + auditStore !== undefined ? createAuditCollector(sessionId) : undefined; + + // When an audit collector is present, intercept the reactor's event stream + // to feed it tool.start / tool.done events (the collector correlates these + // with authz decisions by callId). message.received is reactor-internal and + // is forwarded to the caller but not to the collector. Without a collector, + // the caller's onEvent is used directly. + const composedOnEvent = + auditCollector !== undefined + ? (event: ReactorEmittedEvent) => { + if (event.type !== "message.received") { + auditCollector.onEvent(event); + } + onEvent(event); + } + : onEvent; + + // Authz is composed in front of any caller-supplied before-tool extensions + // so policy enforcement runs first. Without authz, the caller's list (if + // any) is passed through unchanged. + const authzExtension = + authorize !== undefined + ? createAuthzExtension({ + authorize, + ...(auditCollector !== undefined + ? { onDecision: (d) => auditCollector.onDecision(d) } + : {}), + ...(toolDefinitions !== undefined ? { toolDefinitions } : {}), + }) + : undefined; + + const composedBeforeToolExtensions: BeforeToolExtension[] | undefined = + authzExtension !== undefined + ? [authzExtension, ...(callerBeforeToolExtensions ?? [])] + : callerBeforeToolExtensions; + + // The size-cap transform is always prepended so oversized payloads spill + // before any caller transform sees them. Caller transforms run after and + // can rely on the inline content already being bounded. + const sizeCapTransform = createSizeCapTransform({ + maxChars: sizeCapMaxChars ?? DEFAULT_SIZE_CAP_MAX_CHARS, + contextStore, + }); + const composedToolResultTransforms: ToolResultTransform[] = [ + sizeCapTransform, + ...(callerToolResultTransforms ?? []), + ]; + + // Audit flush wraps the caller's lifecycle hooks: the helper's flush runs + // first so the records produced by the just-completed cycle are persisted + // before the caller's hook observes the checkpoint or shutdown boundary. + async function flushAudit(): Promise { + if (auditCollector === undefined || auditStore === undefined) return; + const records = auditCollector.flush(); + if (records.length > 0) { + await auditStore.commitAudit(records); + } + } + + const composedAfterCheckpoint: (() => Promise) | undefined = + auditCollector !== undefined + ? async () => { + await flushAudit(); + if (callerAfterCheckpoint !== undefined) { + await callerAfterCheckpoint(); + } + } + : callerAfterCheckpoint; + + const composedOnShutdown: (() => Promise) | undefined = + auditCollector !== undefined + ? async () => { + const inflight = auditCollector.pending(); + if (inflight > 0) { + logger.warn`${inflight} audit records in flight at shutdown, these tool calls will not be recorded`; + } + await flushAudit(); + if (callerOnShutdown !== undefined) { + await callerOnShutdown(); + } + } + : callerOnShutdown; + + // exactOptionalPropertyTypes is on: only set optional keys when defined. + const reactorConfig: ReactorConfig = { + sessionId, + director, + source, + ...(failOverToNextSource !== undefined ? { failOverToNextSource } : {}), + ...(resetToPreferredSource !== undefined ? { resetToPreferredSource } : {}), + toolRunner, + contextStore, + onEvent: composedOnEvent, + deps, + toolResultTransforms: composedToolResultTransforms, + ...(composedBeforeToolExtensions !== undefined + ? { beforeToolExtensions: composedBeforeToolExtensions } + : {}), + ...(contextTransforms !== undefined ? { contextTransforms } : {}), + ...(compactors !== undefined ? { compactors } : {}), + ...(composedAfterCheckpoint !== undefined + ? { afterCheckpoint: composedAfterCheckpoint } + : {}), + ...(composedOnShutdown !== undefined + ? { onShutdown: composedOnShutdown } + : {}), + ...(correlationValidator !== undefined ? { correlationValidator } : {}), + ...(inferenceRunner !== undefined ? { inferenceRunner } : {}), + ...(gateTimeout !== undefined ? { gateTimeout } : {}), + ...(shutdownTimeoutMs !== undefined ? { shutdownTimeoutMs } : {}), + ...(doomLoopThreshold !== undefined ? { doomLoopThreshold } : {}), + }; + + const reactor = createReactor(reactorConfig); + const blobReader = createBlobReader(contextStore); + + return { reactor, blobReader, auditCollector }; +} diff --git a/vendor/intx/inference/src/audit-collector.ts b/vendor/intx/inference/src/audit-collector.ts new file mode 100644 index 000000000..6239ddf9f --- /dev/null +++ b/vendor/intx/inference/src/audit-collector.ts @@ -0,0 +1,172 @@ +// Audit collector: accumulates tool invocation records for persistence. +// +// The collector correlates three data sources into complete AuditRecord +// objects: +// 1. tool.start events — tool name and arguments (allowed calls only) +// 2. AuthzDecision via onDecision — governance decision +// 3. tool.done events — result and completion metadata +// +// Correlation is by callId. For blocked calls, no tool.start is emitted; +// the collector creates the record from the buffered decision and the +// tool.done event alone. +// +// Wiring: the caller must connect onDecision to the authz extension's +// onDecision callback, and onEvent to the reactor's event stream. The +// types alone do not enforce this — it is a composition-layer concern. + +import type { AuditRecord, AuditAuthz } from "@intx/types/audit"; +import type { InferenceEvent } from "@intx/types/runtime"; +import { getLogger } from "@intx/log"; +import type { AuthzDecision } from "./authz-extension"; + +const logger = getLogger(["interchange", "audit-collector"]); + +type PendingRecord = { + callId: string; + tool: string; + arguments: Record; + authz: AuditAuthz | null; +}; + +export type AuditCollector = { + onEvent(event: InferenceEvent): void; + onDecision(decision: AuthzDecision): void; + flush(): AuditRecord[]; + pending(): number; +}; + +function coerceContent(content: unknown): string | Record { + if (typeof content === "string") return content; + if (typeof content === "object" && content !== null) { + // content is a non-null object — compatible with Record + // but TypeScript can't verify the index signature without a cast. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-null object is structurally compatible with Record but TS won't widen + return content as Record; + } + throw new Error(`Unexpected tool result content type: ${typeof content}`); +} + +function mapGrant(g: AuthzDecision["matchingGrants"][number]) { + return { + id: g.id, + resource: g.resource, + action: g.action, + effect: g.effect, + origin: g.origin, + specificity: g.specificity, + }; +} + +function decisionToAuthz(d: AuthzDecision): AuditAuthz { + return { + effect: d.effect, + resolvedBy: d.resolvedBy ? mapGrant(d.resolvedBy) : null, + matchingGrants: d.matchingGrants.map(mapGrant), + blocked: d.blocked, + ...(d.blockReason !== undefined ? { blockReason: d.blockReason } : {}), + }; +} + +export function createAuditCollector(sessionId: string): AuditCollector { + const decisions = new Map(); + const pendingRecords = new Map(); + const completed: AuditRecord[] = []; + + function onDecision(decision: AuthzDecision): void { + decisions.set(decision.callId, decision); + } + + function onEvent(event: InferenceEvent): void { + if (event.type === "tool.start") { + const call = event.data.call; + const decision = decisions.get(call.id); + decisions.delete(call.id); + + pendingRecords.set(call.id, { + callId: call.id, + tool: call.name, + arguments: call.arguments, + authz: decision ? decisionToAuthz(decision) : null, + }); + return; + } + + if (event.type === "tool.done") { + const result = event.data.result; + const pending = pendingRecords.get(result.callId); + + if (pending) { + pendingRecords.delete(result.callId); + completed.push({ + callId: pending.callId, + tool: pending.tool, + arguments: pending.arguments, + authz: pending.authz, + result: { + content: coerceContent(result.content), + isError: result.isError === true, + }, + timestamp: new Date().toISOString(), + sessionId, + seq: event.seq, + }); + return; + } + + // Blocked call: no tool.start was emitted. Build the record from + // the buffered decision and the tool.done event. + const decision = decisions.get(result.callId); + if (decision === undefined) { + // Orphaned tool.done: no tool.start or authz decision was recorded. + // Emit a degraded record rather than crashing the session — the audit + // system is observational infrastructure and must not veto execution. + + logger.warn`Orphaned tool.done for callId "${result.callId}": no tool.start or authz decision was recorded`; + completed.push({ + callId: result.callId, + tool: "$orphaned", + arguments: {}, + authz: null, + result: { + content: coerceContent(result.content), + isError: result.isError === true, + }, + timestamp: new Date().toISOString(), + sessionId, + seq: event.seq, + }); + return; + } + decisions.delete(result.callId); + + completed.push({ + callId: result.callId, + tool: decision.tool, + arguments: {}, + authz: decisionToAuthz(decision), + result: { + content: coerceContent(result.content), + isError: result.isError === true, + }, + timestamp: new Date().toISOString(), + sessionId, + seq: event.seq, + }); + } + } + + function flush(): AuditRecord[] { + return completed.splice(0); + } + + function pendingCount(): number { + return pendingRecords.size + decisions.size; + } + + return { + onEvent, + onDecision, + flush, + pending: pendingCount, + }; +} diff --git a/vendor/intx/inference/src/auth.ts b/vendor/intx/inference/src/auth.ts new file mode 100644 index 000000000..dba0cd54c --- /dev/null +++ b/vendor/intx/inference/src/auth.ts @@ -0,0 +1,61 @@ +import type { InferenceSource } from "@intx/types/runtime"; + +// Sentinel placeholder strings adapters use in their built request +// headers to declare which credential the harness should fill at send +// time. The harness scans every header value and replaces exact-match +// sentinels with material derived from `InferenceSource.apiKey`. Adapters +// never see the API key. +// +// Each new provider adds a new header name + sentinel choice in its +// `buildRequest`; the harness needs no per-provider knowledge. The +// alternative pattern -- a switch in the harness keyed on header name +// -- was abandoned because the constraint ("how does this provider +// want its credential delivered") lives with the adapter, not with the +// harness, and growing a hardcoded branch per provider violates the +// constraint-ownership rule. +// +// The sentinel strings deliberately contain angle brackets and a +// keyword prefix that would never appear in a legitimate header value: +// matching is exact, but defense-in-depth ensures a literal echo from +// an upstream system can't accidentally trigger replacement. + +/** + * Sentinel for headers that carry the API key verbatim (no prefix). + * Used by providers like Anthropic (`x-api-key`) and Google + * (`x-goog-api-key`) that accept the raw credential. + */ +export const CREDENTIAL_SENTINEL = ""; + +/** + * Sentinel for headers that carry a Bearer-prefixed API key. Used by + * providers that follow the `Authorization: Bearer ` convention + * (OpenAI, OpenAI-compatible). + */ +export const BEARER_CREDENTIAL_SENTINEL = ""; + +/** + * Replace credential sentinels in a header map with material derived + * from the inference source. Returns a new object; the input is not + * mutated. Non-sentinel header values pass through unchanged. + * + * A header value that contains a sentinel as a substring but is not + * exactly equal to it is left alone -- partial replacement would be + * surprising, and no legitimate adapter constructs sentinel-bearing + * composite values. + */ +export function injectCredentials( + headers: Record, + source: InferenceSource, +): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (value === CREDENTIAL_SENTINEL) { + result[name] = source.apiKey; + } else if (value === BEARER_CREDENTIAL_SENTINEL) { + result[name] = `Bearer ${source.apiKey}`; + } else { + result[name] = value; + } + } + return result; +} diff --git a/vendor/intx/inference/src/authz-extension.ts b/vendor/intx/inference/src/authz-extension.ts new file mode 100644 index 000000000..d5f8f8527 --- /dev/null +++ b/vendor/intx/inference/src/authz-extension.ts @@ -0,0 +1,273 @@ +// Authz-based BeforeToolExtension. +// +// Creates an extension that authorizes tool calls against a policy before +// execution. The caller provides a pre-bound authorize function that +// encapsulates store, principal, tenant, and condition registry details. +// +// Effects: +// allow → tool proceeds +// deny → tool blocked +// 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 +// additional action granularity is needed later, the action becomes a +// parameter. +// +// Signal propagation into the authorize function is deferred — the caller +// can capture the signal in their closure if cancellation is needed. +// +// The onDecision callback must not throw. If it does, the exception is +// logged but swallowed so it cannot interfere with the authorization +// decision or mask the original error. + +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; + action: string; + effect: Effect; + origin: "system" | "role" | "creator" | "invoker"; + specificity: number; +}; + +export type AuthzCallResult = { + effect: Effect | null; + matchingGrants: AuthzMatchedGrant[]; + resolvedBy: AuthzMatchedGrant | null; +}; + +export type AuthzDecision = { + callId: string; + tool: string; + resource: string; + action: string; + effect: Effect | null; + resolvedBy: AuthzMatchedGrant | null; + matchingGrants: AuthzMatchedGrant[]; + blocked: boolean; + blockReason: string | undefined; + error: string | undefined; +}; + +export type AuthzExtensionOptions = { + authorize: ( + resource: string, + action: string, + 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" | null; + +function formatBlockReason( + effect: BlockEffect, + resource: string, + action: string, +): string { + switch (effect) { + case "deny": + return `Denied by policy: ${resource}/${action}`; + case null: + return `No matching grants for ${resource}/${action}`; + } +} + +function safeOnDecision( + callback: ((decision: AuthzDecision) => void) | undefined, + decision: AuthzDecision, +): void { + if (callback === undefined) return; + try { + callback(decision); + } catch { + // onDecision must not throw. If it does, swallow the exception so + // it cannot interfere with the authorization decision or mask the + // original error from authorize(). + } +} + +export function createAuthzExtension( + opts: AuthzExtensionOptions, +): BeforeToolExtension { + // The reactor does not know workflow concepts; per-call context is the + // caller's domain. The third arg is plumbing here -- if the caller + // needs to attach context (workflow step, tenant id, request id), they + // do so by closure on the authorize function. The empty object is the + // 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"; + + let result: AuthzCallResult; + try { + result = await opts.authorize(resource, action, emptyContext); + } catch (cause) { + const msg = cause instanceof Error ? cause.message : String(cause); + const decision: AuthzDecision = { + callId: call.id, + tool: call.name, + resource, + action, + effect: null, + resolvedBy: null, + matchingGrants: [], + blocked: true, + blockReason: `Authorization failed: ${msg}`, + error: msg, + }; + safeOnDecision(opts.onDecision, decision); + throw cause; + } + + // 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 === null + ? formatBlockReason(result.effect, resource, action) + : undefined; + + const decision: AuthzDecision = { + callId: call.id, + tool: call.name, + resource, + action, + effect: result.effect, + resolvedBy: result.resolvedBy, + matchingGrants: result.matchingGrants, + blocked: blockReason !== undefined, + blockReason, + error: undefined, + }; + safeOnDecision(opts.onDecision, decision); + + // 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 new file mode 100644 index 000000000..974a03d03 --- /dev/null +++ b/vendor/intx/inference/src/correlation.ts @@ -0,0 +1,68 @@ +// Correlation registry and validator interface for the agent reactor. +// +// Correlation connects outbound async tool calls to inbound responses. The +// reactor owns the matching; the director does not participate. +// +// (INFERENCE.md § Correlation) + +import type { InboundMessage, PendingOperation } from "@intx/types/runtime"; + +/** + * Validates whether an inbound message is an authentic response to a + * registered pending operation. Consumers provide this at reactor construction + * time to enforce sender identity and signature checks. + */ +export interface CorrelationValidator { + /** + * Return true if `message` is a valid resolution for `pending`. + * False causes the message to be delivered as a regular uncorrelated event. + */ + validate( + pending: PendingOperation, + message: InboundMessage, + ): Promise; +} + +/** + * Tracks pending async operations. Each entry maps a correlation ID to the + * operation metadata and the gate that is waiting for it. + */ +export function createCorrelationRegistry() { + const operations = new Map(); + + function register(op: PendingOperation): void { + if (operations.has(op.correlationId)) { + throw new Error( + `Correlation ID "${op.correlationId}" is already registered`, + ); + } + operations.set(op.correlationId, op); + } + + function lookup(correlationId: string): PendingOperation | undefined { + 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); + } + + function all(): PendingOperation[] { + return Array.from(operations.values()); + } + + function hasAny(): boolean { + return operations.size > 0; + } + + return { register, lookup, findByGateId, remove, all, hasAny }; +} + +export type CorrelationRegistry = ReturnType; diff --git a/vendor/intx/inference/src/default-director.ts b/vendor/intx/inference/src/default-director.ts new file mode 100644 index 000000000..e9c2dbd57 --- /dev/null +++ b/vendor/intx/inference/src/default-director.ts @@ -0,0 +1,386 @@ +// Default conversational director — reference ReactorDirector implementation. +// +// Inbound-event → action map (see INFERENCE.md § Director Decision Function +// for the director contract and action-validation rules): +// +// message.received → infer +// inference.done (tools) → checkpoint + execute_tools +// tool.done → checkpoint + infer (re-infer with tool results) +// inference.done (no tools) → checkpoint + reply (connector sends the message) +// 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 +// event map above. See AfterInferenceDecision for that contract. +// +// The director never throws. Inference errors are surfaced to the user as a +// reply so the problem is visible, and the agent remains alive for retries. + +import { getLogger } from "@intx/log"; +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"]); + +/** + * Decision returned by an `afterInferenceDone` policy hook. + * + * continue — proceed with the director's normal post-inference logic + * (tool extraction, reply, or wait per the existing flow). + * abort — terminate the agent. Routes to `[checkpoint, done]` and + * the reactor shuts down. Stronger than the + * `inference.error` branch, which only replies and stays + * alive — `abort` is for "session is over, do not accept + * further inputs." + * halt — pause the current cycle without terminating. Routes to + * `[checkpoint, reply]`; the reply returns the reactor to + * waiting for the next inbound event, so it stays alive. + * There is no auto-resume; an external event (mail, gate + * clearance, etc.) must reach the reactor for the agent to + * make progress again. + * + * `reason` on a `halt` becomes the connector reply text verbatim, so + * policy authors choose what is safe to surface to the user. On an + * `abort` the reason is not surfaced: a terminal action cannot carry a + * reply, since a reply invites continuation. Delivering an abort reason + * to the user needs a dedicated terminal-notice path, which does not + * exist today. + */ +export type AfterInferenceDecision = + | { type: "continue" } + | { type: "abort"; reason: string } + | { type: "halt"; reason: string }; + +/** + * Function shape for an after-inference-done policy hook. + * + * The hook fires only on `inference.done` (a successful cycle). Errored + * cycles do not invoke it. `mode: "reactive"` does not change firing — + * the hook gates the entire `inference.done` branch, including the + * reactive-wait shortcut, so a budget check applies to reactive agents + * the same way it does to conversational ones. + * + * The hook receives the post-cycle `ReactorState` (with `lastCycleSource` + * and `lastCycleUsage` populated for the just-completed call) and the + * assistant turn. Returns a decision (sync or async) that controls + * whether the director continues, terminates the agent, or pauses the + * cycle. + * + * Canonical use case: cost-aware gating. Read `state.lastCycleSource` + * + `state.lastCycleUsage`, price the call against user-supplied rate + * data, decide whether the budget is exhausted. Token caps, time caps, + * wallet checks, and governance triggers fit the same shape; the + * type stays policy-agnostic. + * + * "Downgrade to cheaper model" policies do NOT use this hook to return + * a new source. Compose them via an external observer of + * `lastCycleSource` / `lastCycleUsage` that calls `setSource` from + * outside the director. + * + * The hook blocks the reactor's inference.done branch: keep its + * latency low. The return type admits a Promise, but every await + * inside the hook is wall-clock time the agent isn't making progress. + * Small lookups (in-memory caches, fast DB reads) are fine; arbitrary + * waits are not. + * + * Tool calls and `halt`: if the model emitted tool calls and the hook + * returns `halt` (or `abort`), those tool calls are dropped — the + * director never executes them. On resume, the model's next inference + * sees an assistant turn with unanswered tool calls; depending on the + * provider this is either a validation error or a confused model. + * Policy authors that combine `halt` with tool-heavy agents need to + * understand this. + */ +export type AfterInferenceHook = ( + state: ReactorState, + turn: AssistantTurn, +) => AfterInferenceDecision | Promise; + +export type DefaultDirectorPolicy = { + /** + * Controls the agent's behavior after inference completes. + * + * "conversational" (default) — The standard agentic loop. After tools + * complete, re-infer so the model can reason about results, issue more + * tool calls, or compose a reply. When inference produces text without + * tool calls, send it as a connector reply. + * + * "reactive" — The agent acts on each message by executing tools, then + * returns to the event loop to wait for the next inbound event. It does + * not re-infer after tools complete and does not send connector replies. + * Use this for agents that perform a single action per message. + */ + mode?: "conversational" | "reactive"; + + /** + * Optional policy hook fired after every successful `inference.done`. + * See `AfterInferenceHook` for the contract: firing boundary, return + * shape, composition patterns, and policy-author caveats. + * + * If the hook throws or rejects, the director catches the error, + * routes to `{ type: "abort", reason: "afterInferenceDone policy + * threw: " }`, and logs at error level. The director's + * never-throws contract is preserved. + */ + afterInferenceDone?: AfterInferenceHook; +}; + +function extractToolCalls(turn: AssistantTurn): ToolCall[] { + const calls: ToolCall[] = []; + for (const block of turn.content) { + if (block.type === "tool_call") { + calls.push({ + id: block.id, + name: block.name, + arguments: block.arguments, + }); + } + } + return calls; +} + +function extractTextContent(turn: AssistantTurn): string { + // 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(); +} + +const ERROR_PREAMBLE: Record = { + credential_failure: + "This agent could not complete your request due to a credential error", + quota_exhausted: + "This agent could not complete your request because the API quota has been exhausted", + context_overflow: + "This agent could not complete your request because the conversation exceeded the model's context limit", + retryable: + "This agent encountered a temporary error communicating with the inference provider", + fatal: + "This agent could not complete your request due to an unrecoverable inference error", + aborted: "This agent's inference request was aborted", +}; + +function formatInferenceError(error: { + category: string; + message: string; + statusCode?: number; +}): string { + const preamble = ERROR_PREAMBLE[error.category] ?? ERROR_PREAMBLE["fatal"]; + const status = + error.statusCode !== undefined ? ` [HTTP ${error.statusCode}]` : ""; + return `${preamble}${status}: ${error.message}`; +} + +export class DefaultDirector implements ReactorDirector { + private readonly systemPrompt: string; + private readonly toolDefinitions: ToolDefinition[]; + private readonly policy: DefaultDirectorPolicy; + + // Track outstanding tool results so we only re-infer once per batch. + private pendingToolResults = 0; + + constructor( + systemPrompt: string, + toolDefinitions: ToolDefinition[] = [], + policy: DefaultDirectorPolicy = {}, + ) { + this.systemPrompt = systemPrompt; + this.toolDefinitions = toolDefinitions; + this.policy = policy; + } + + async decide( + event: ReactorInboundEvent, + state: ReactorState, + capabilities: ReactorCapabilities, + ): Promise { + switch (event.type) { + case "message.received": { + return capabilities.infer({ + systemPrompt: this.systemPrompt, + tools: this.toolDefinitions, + }); + } + + case "inference.done": { + // The hook gates the entire inference.done branch (including + // tool extraction and the reactive-mode wait shortcut). An + // abort/halt from the policy drops any tool calls the model + // emitted in this turn; see AfterInferenceHook TSDoc for the + // implications. + if (this.policy.afterInferenceDone !== undefined) { + let decision: AfterInferenceDecision; + try { + decision = await this.policy.afterInferenceDone(state, event.turn); + } catch (cause) { + const message = + cause instanceof Error ? cause.message : String(cause); + logger.error`afterInferenceDone policy threw: ${message}`; + decision = { + type: "abort", + reason: `afterInferenceDone policy threw: ${message}`, + }; + } + if (decision.type === "abort") { + // A reply invites the next inbound message, but abort is + // terminal — the reactor rejects reply paired with done. The + // reason is therefore not surfaced on this path. + return [ + capabilities.checkpoint("after-inference-abort"), + capabilities.done(), + ]; + } + if (decision.type === "halt") { + // A reply already returns the reactor to waiting for the next + // inbound message, so no separate wait is needed (and the + // reactor rejects reply paired with wait). + return [ + capabilities.checkpoint("after-inference-halt"), + capabilities.reply(decision.reason), + ]; + } + // decision.type === "continue" — fall through. + } + + const toolCalls = extractToolCalls(event.turn); + if (toolCalls.length > 0) { + this.pendingToolResults = toolCalls.length; + return [ + capabilities.checkpoint("tool-execution"), + capabilities.executeTools(toolCalls, true), + ]; + } + + // No tool calls — the model is done reasoning for this turn. + if (this.policy.mode === "reactive") { + return [ + capabilities.checkpoint("inference-done"), + capabilities.wait(), + ]; + } + + // Conversational agent: send reply via the connector. + const replyContent = extractTextContent(event.turn); + if (replyContent.length > 0) { + return [ + capabilities.checkpoint("inference-done"), + capabilities.reply(replyContent), + ]; + } + + // Empty response (no text, no tool calls) — checkpoint and wait for + // the next inbound message. The reactor only shuts down on explicit + // stop (abort), never because the model produced an empty turn. + 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) { + return []; + } + if (this.policy.mode === "reactive") { + return [capabilities.checkpoint("tool-done"), capabilities.wait()]; + } + // All tool results received — re-infer with complete context. + return [ + capabilities.checkpoint("tool-done"), + capabilities.infer({ + systemPrompt: this.systemPrompt, + tools: this.toolDefinitions, + }), + ]; + } + + case "inference.error": { + const statusDetail = + event.error.statusCode !== undefined + ? ` [HTTP ${event.error.statusCode}]` + : ""; + + logger.error`Inference error in default director: ${event.error.message}${statusDetail} (category: ${event.error.category})`; + + const userMessage = formatInferenceError(event.error); + return [ + capabilities.checkpoint("inference-error"), + capabilities.reply(userMessage), + ]; + } + + case "reactor.gate.cleared": { + return [ + capabilities.checkpoint("gate-cleared"), + capabilities.infer({ + systemPrompt: this.systemPrompt, + tools: this.toolDefinitions, + }), + ]; + } + + case "abort": { + return capabilities.done(); + } + } + } +} + +export function createDefaultDirector( + systemPrompt: string, + toolDefinitions: ToolDefinition[] = [], + policy: DefaultDirectorPolicy = {}, +): ReactorDirector { + return new DefaultDirector(systemPrompt, toolDefinitions, policy); +} diff --git a/vendor/intx/inference/src/director.ts b/vendor/intx/inference/src/director.ts new file mode 100644 index 000000000..b67a1ed98 --- /dev/null +++ b/vendor/intx/inference/src/director.ts @@ -0,0 +1,87 @@ +// Director interface types and capabilities factory. +// +// The capabilities object is passed to the director on every decision call. +// It provides a type-safe API for constructing reactor actions without +// requiring the director to import or construct action literals directly. +// +// (INFERENCE.md § Reactor Director › Core Director) + +import type { + ReactorAction, + ReactorCapabilities, + GateType, + ForkMode, + InferenceOptions, + ToolCall, +} from "@intx/types/runtime"; + +/** + * Builds a frozen capabilities object. The same instance is reused across + * calls since all methods are pure constructors. + */ +export function createCapabilities(): ReactorCapabilities { + return { + infer(options?: InferenceOptions): ReactorAction { + return { + type: "infer", + ...(options !== undefined ? { options } : {}), + }; + }, + + executeTools( + calls: ToolCall[], + parallel?: boolean, + addToHistory?: boolean, + ): ReactorAction { + return { + type: "execute_tools", + calls, + ...(parallel !== undefined ? { parallel } : {}), + ...(addToHistory !== undefined ? { addToHistory } : {}), + }; + }, + + suspend(gate: { + type: GateType; + gateId: string; + timeoutMs: number; + correlationId?: string; + }): ReactorAction { + return { type: "suspend", gate }; + }, + + fork(mode: ForkMode, forkId: string): ReactorAction { + return { type: "fork", mode, forkId }; + }, + + emit( + eventType: `custom.${string}`, + data: Record, + ): ReactorAction { + return { type: "emit", eventType, data }; + }, + + reply(content: string): ReactorAction { + return { type: "reply", content }; + }, + + checkpoint(reason?: string): ReactorAction { + return { + type: "checkpoint", + message: reason !== undefined ? `checkpoint: ${reason}` : "checkpoint", + }; + }, + + compact(compactor: string, reason: string): ReactorAction { + return { type: "compact", compactor, reason }; + }, + + wait(): ReactorAction { + return { type: "wait" }; + }, + + done(): ReactorAction { + return { type: "done" }; + }, + }; +} diff --git a/vendor/intx/inference/src/errors.ts b/vendor/intx/inference/src/errors.ts new file mode 100644 index 000000000..75207ce02 --- /dev/null +++ b/vendor/intx/inference/src/errors.ts @@ -0,0 +1,115 @@ +import type { InferenceError } from "@intx/types/runtime"; + +export type { InferenceError }; + +export function classifyHTTPError( + statusCode: number, + message: string, + raw?: unknown, + retryAfterMs?: number, +): InferenceError { + if (statusCode === 401 || statusCode === 403) { + return { category: "credential_failure", message, statusCode, raw }; + } + + if (statusCode === 429) { + return { + category: "quota_exhausted", + message, + statusCode, + ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), + raw, + }; + } + + if (statusCode === 400) { + // Context-overflow manifests as a 400 with a provider-specific message. + // Check for known patterns before falling through to fatal. + if (isContextOverflowMessage(message)) { + return { category: "context_overflow", message, statusCode, raw }; + } + return { category: "fatal", message, statusCode, raw }; + } + + if (statusCode >= 500 && statusCode < 600) { + return { category: "retryable", message, statusCode, raw }; + } + + return { category: "fatal", message, statusCode, raw }; +} + +export function classifyNetworkError(cause: unknown): InferenceError { + const message = cause instanceof Error ? cause.message : String(cause); + return { category: "retryable", message, raw: cause }; +} + +export function classifyAbortError(): InferenceError { + return { category: "aborted", message: "inference aborted" }; +} + +export function classifyTimeoutError( + kind: "inactivity" | "total", + thresholdMs: number, +): InferenceError { + const message = + kind === "inactivity" + ? `inference call exceeded inactivity timeout (${String(thresholdMs)} ms with no events from the provider)` + : `inference call exceeded total timeout (${String(thresholdMs)} ms wall-clock)`; + return { category: "timeout", message }; +} + +/** + * The one throw type a response parser is permitted to raise. See the + * `ResponseParser` contract on `adapter.ts` for full semantics. `raw` + * carries the offending bytes or parsed object so operators can + * inspect what came over the wire. + */ +export class ProtocolMismatchError extends Error { + readonly raw: unknown; + constructor(detail: string, raw?: unknown) { + super(detail); + this.name = "ProtocolMismatchError"; + this.raw = raw; + } +} + +export function classifyProtocolMismatch( + detail: string, + raw?: unknown, +): InferenceError { + return { + category: "protocol_mismatch", + message: detail, + ...(raw !== undefined ? { raw } : {}), + }; +} + +export function classifyStreamError(cause: unknown): InferenceError { + if (isAbortError(cause)) { + return classifyAbortError(); + } + if (cause instanceof ProtocolMismatchError) { + return classifyProtocolMismatch(cause.message, cause.raw); + } + const message = cause instanceof Error ? cause.message : String(cause); + return { category: "retryable", message, raw: cause }; +} + +function isContextOverflowMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("context_length_exceeded") || + lower.includes("context length") || + lower.includes("too many tokens") || + lower.includes("maximum context") || + lower.includes("input is too long") + ); +} + +function isAbortError(value: unknown): boolean { + return ( + value instanceof Error && + (value.name === "AbortError" || + value.message === "The user aborted a request.") + ); +} diff --git a/vendor/intx/inference/src/gates.ts b/vendor/intx/inference/src/gates.ts new file mode 100644 index 000000000..4de576f1c --- /dev/null +++ b/vendor/intx/inference/src/gates.ts @@ -0,0 +1,151 @@ +// Gate management for the agent reactor. +// +// Gates block the reactor until an external condition resolves. Each gate has +// a type, an ID, and a mandatory timeout. The gate manager owns all active +// gates and exposes methods to register, clear, and time out gates. +// +// (INFERENCE.md § Gates, Gate Timeouts, Gate Behavior During Suspension) + +import type { GateType } from "@intx/types/runtime"; + +export type GateRecord = { + gateId: string; + type: GateType; + timeoutAt: number; + correlationId: string | undefined; + resolve: (reason: "resolved" | "timeout" | "shutdown") => void; + onCleared: ( + gateId: string, + reason: "resolved" | "timeout" | "shutdown", + ) => void; + timer: ReturnType; +}; + +export type GateSnapshot = { + gateId: string; + type: GateType; + timeoutAt: number; +}; + +/** + * Manages active gates. All gates must have a positive timeout. + */ +export function createGateManager() { + const gates = new Map(); + + function register( + gateId: string, + type: GateType, + timeoutMs: number, + correlationId: string | undefined, + onCleared: ( + gateId: string, + reason: "resolved" | "timeout" | "shutdown", + ) => void, + ): Promise<"resolved" | "timeout" | "shutdown"> { + if (timeoutMs <= 0) { + throw new Error( + `Gate "${gateId}" must have a positive timeout (got ${timeoutMs})`, + ); + } + + if (gates.has(gateId)) { + throw new Error(`Gate "${gateId}" is already registered`); + } + + const timeoutAt = Date.now() + timeoutMs; + let resolveGate!: (reason: "resolved" | "timeout" | "shutdown") => void; + + const promise = new Promise<"resolved" | "timeout" | "shutdown">( + (resolve) => { + resolveGate = resolve; + }, + ); + + const timer = setTimeout(() => { + if (gates.has(gateId)) { + gates.delete(gateId); + resolveGate("timeout"); + onCleared(gateId, "timeout"); + } + }, timeoutMs); + + gates.set(gateId, { + gateId, + type, + timeoutAt, + correlationId, + resolve: resolveGate, + onCleared, + timer, + }); + + return promise; + } + + function clear(gateId: string): boolean { + const gate = gates.get(gateId); + if (gate === undefined) return false; + clearTimeout(gate.timer); + gates.delete(gateId); + gate.resolve("resolved"); + gate.onCleared(gateId, "resolved"); + 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(); + for (const gate of entries) { + clearTimeout(gate.timer); + gate.resolve("shutdown"); + gate.onCleared(gate.gateId, "shutdown"); + } + } + + function findByCorrelationId(correlationId: string): GateRecord | undefined { + for (const gate of gates.values()) { + if (gate.correlationId === correlationId) return gate; + } + return undefined; + } + + function snapshot(): GateSnapshot[] { + return Array.from(gates.values()).map((g) => ({ + gateId: g.gateId, + type: g.type, + timeoutAt: g.timeoutAt, + })); + } + + function has(gateId: string): boolean { + return gates.has(gateId); + } + + return { + register, + clear, + clearSilently, + shutdown, + findByCorrelationId, + snapshot, + has, + }; +} + +export type GateManager = ReturnType; diff --git a/vendor/intx/inference/src/harness.ts b/vendor/intx/inference/src/harness.ts new file mode 100644 index 000000000..8c1e85f4e --- /dev/null +++ b/vendor/intx/inference/src/harness.ts @@ -0,0 +1,1720 @@ +// Shared streaming harness — the 8-step pipeline described in INFERENCE.md. +// +// The harness: +// 1. Opens an HTTP connection with the adapter's built request +// 2. Parses the SSE byte stream into data lines +// 3. Passes each data line to the adapter's response parser +// 4. Accumulates partial message state from parser output +// 5. Emits events on the common event protocol +// 6. Checks AbortSignal between chunks +// 7. On error: classifies, emits inference.error, cleans up +// 8. On completion: emits inference.usage + inference.done +// +// Provider adapters never touch SSE parsing, connection lifecycle, abort +// handling, or event emission. They translate request/response shapes. + +import { type } from "arktype"; + +import type { + CitationBlock, + CodeExecutionRequestBlock, + CodeExecutionResultBlock, + ConversationTurn, + ImageBlock, + InferenceError, + InferenceEvent, + InferenceOptions, + InferenceSource, + LastCycleSource, + PartialMessage, + RetryDecision, + SafetyRatingBlock, + TokenUsage, + AssistantTurn, + ContentBlock, +} from "@intx/types/runtime"; + +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"; +import { + classifyHTTPError, + classifyNetworkError, + classifyAbortError, + classifyStreamError, + classifyTimeoutError, + classifyProtocolMismatch, + ProtocolMismatchError, +} from "./errors"; +import { createDefaultRetryPolicy } from "./retry-policy"; + +const logger = getLogger(["interchange", "inference", "harness"]); + +/** + * Default per-call inactivity timeout (ms). Two minutes is conservative + * for reasoning-heavy models that emit `inference.thinking.delta` tokens + * regularly when actually working — sustained silence past this means + * the provider stream has genuinely stalled, not that the model is + * thinking. Operators can tune via `InferenceOptions.inactivityTimeoutMs`. + */ +export const DEFAULT_INACTIVITY_TIMEOUT_MS = 120_000; + +/** + * Default per-call total wall-clock cap (ms). Matches Anthropic's + * documented per-call recommendation and fits within typical CI + * timeouts. Operators can tune via `InferenceOptions.totalTimeoutMs`. + */ +export const DEFAULT_TOTAL_TIMEOUT_MS = 600_000; + +export const HarnessId: unique symbol = Symbol("HarnessId"); + +/** + * Runtime dependencies injected into `runInference`. Code-only — not part of + * any persisted schema. Test harnesses substitute `fetch` (and stamp the + * `[HarnessId]` tag for per-harness identity) so production `runInference` + * never reaches `globalThis.fetch`. + * + * `fetch` is intentionally typed as a plain function rather than + * `typeof globalThis.fetch` — the latter is augmented per-runtime (Bun adds + * `preconnect`; Node and the DOM lib do not) and `runInference` only ever + * invokes the call signature. + * + * The `[HarnessId]` tag is enumerable via `Object.getOwnPropertySymbols` + * (and `Reflect.ownKeys`, which is the superset). Do not pass `Dependencies` + * instances through reflective serializers or expose them across trust + * boundaries. (`JSON.stringify` is safe — it walks string keys only.) + */ +export type Dependencies = { + readonly fetch: ( + input: string | URL | Request, + init?: RequestInit, + ) => Promise; + /** + * Time-based scheduler used by the harness's per-call timeouts (see + * `InferenceOptions.inactivityTimeoutMs` / `totalTimeoutMs`). Production + * passes the default wrapper around `setTimeout` / `clearTimeout`; the + * deterministic test harness injects a scheduler that wraps its virtual + * clock so timeout tests fire at virtual-time-N without sleeping real + * wall-clock. Required — every caller must make an explicit choice + * between the production scheduler and a virtual one. Use + * `createDefaultScheduler()` for the production default. + */ + readonly scheduler: Scheduler; + /** + * Registry resolving an inference source to its provider adapter. + * `runSingleAttempt` consults this on every call via `adapters.resolve`, + * so it is required — the caller makes an explicit choice of provider set. + * Construct it via `createDependencies(adapters)` (core) or, for the + * built-in set, `@intx/inference/providers`' `createDefaultDependencies()`. + */ + readonly adapters: AdapterRegistry; + readonly [HarnessId]?: symbol; +}; + +/** + * Minimal scheduling abstraction. `setTimeout` returns a canceller; the + * canceller is idempotent (multiple calls are safe). The harness uses + * this for both the inactivity timer (which is re-armed on every event) + * and the total wall-clock cap. `now()` is a monotonic time source in + * the same `delayMs` units `setTimeout` accepts — deltas across two + * `now()` reads describe elapsed time the same way `setTimeout(..., + * delta)` would have measured it. + */ +export type Scheduler = { + setTimeout(callback: () => void, delayMs: number): () => void; + now(): number; +}; + +export function createDefaultScheduler(): Scheduler { + return { + setTimeout(callback, delayMs) { + const handle = setTimeout(callback, delayMs); + return () => { + clearTimeout(handle); + }; + }, + // `performance.now()` is monotonic and survives wall-clock + // adjustments (NTP, daylight-saving) that could otherwise make a + // long-running interval read as negative against `Date.now()`. The + // epoch differs from `Date.now()`, but consumers only ever read + // deltas across two `now()` calls from the same Scheduler instance. + now() { + return performance.now(); + }, + }; +} + +/** + * Construct runtime dependencies for `runInference` from an explicit adapter + * registry, binding `fetch` to `globalThis.fetch` and `scheduler` to the + * production wrapper. The registry is required so the caller makes an explicit + * choice of provider set; `@intx/inference/providers`' zero-arg + * `createDefaultDependencies()` is the honest default that supplies the + * built-in registry. + * + * @param adapters - Registry resolving inference sources to provider adapters + * @returns Fully-populated dependencies + */ +export function createDependencies(adapters: AdapterRegistry): Dependencies { + return { + fetch: globalThis.fetch.bind(globalThis), + scheduler: createDefaultScheduler(), + adapters, + }; +} + +export type InferenceHarnessOptions = { + turns: ConversationTurn[]; + source: InferenceSource; + inferenceOptions?: InferenceOptions; + signal?: AbortSignal; + // Sequence number allocator — called once per event to get the next seq. + nextSeq: () => number; + deps: Dependencies; +}; + +/** + * Run one fetch lifecycle and yield its events. Ends on the first + * `inference.error` or `inference.done`. The outer `runInference` + * consumes this generator, decides retry vs flush per the configured + * `RetryPolicy`, and either flushes the buffered events to the caller + * or discards them and re-enters this generator with the same opts. + * + * Not exported — the wrapper is the public entry point; calling this + * directly would bypass retry handling. + */ +async function* runSingleAttempt( + opts: InferenceHarnessOptions, +): AsyncIterable { + const { turns, source, inferenceOptions, signal, nextSeq, deps } = opts; + // Per-call options override source-bound defaults. The merge happens + // here, once, so the adapter and timeout-resolution paths below all + // see the effective option set without having to remember the + // precedence rule. + const effectiveOptions: InferenceOptions = { + ...(source.defaults ?? {}), + ...(inferenceOptions ?? {}), + }; + const model = source.model; + // Snapshot the source identity at call start. The harness reads + // `source.*` lazily across the rest of this function (and the adapter + // closes over `source` for its parseResponse), so a `setSource` + // mid-call would otherwise mutate the identity stamped onto the + // inference.usage and inference.done events for this very call. + // Capturing into a local LastCycleSource here is the single point + // that defends against that hot-swap. + // + // Scope of the defense: this snapshot protects *identity attribution* + // — what the director's policy hook and external event consumers see + // for `lastCycleSource` and `event.data.source`. It does NOT isolate + // the in-flight HTTP request from the swap: `resolveURL` reads + // `source.baseURL` live and `injectCredentials` reads `source.apiKey` + // live (both below). A mid-call `setSource` will route the request to + // the new endpoint with the new credentials while the resulting + // inference.done still carries the pre-swap identity. That is + // consistent with `LastCycleSource` deliberately excluding + // baseURL/apiKey, but it is worth knowing: the snapshot is + // identity-only, not a transactional freeze of the entire source. + const lastCycleSource: LastCycleSource = { + sourceId: source.id, + provider: source.provider, + model, + }; + + // Emit inference.start immediately. + yield { type: "inference.start", seq: nextSeq(), data: { model } }; + + // Mutable partial state — the harness owns this. + const partial: PartialMessage = { text: "" }; + // Per-index block tracking. The map preserves insertion order (JS + // Map guarantee, even for integer keys — unlike plain objects). + // Each entry records one block's running state; final-turn + // assembly walks the map in arrival order and emits one ContentBlock + // per entry. The `tool_use` entries are index markers only — the + // tool-call state machine lives in `openToolCalls` / + // `completedToolCalls` and is resolved into the final block at + // assembly time via the marker's `callId`. + type BlockState = + | { 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; 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 + // to the block at the matching index and interleave into the + // finalized turn immediately after that block; unindexed citations + // append at the end of `content[]` per the CitationBlock attribution + // rule. The two collections capture distinct semantics, not just + // 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). + type ToolCallState = { + callId: string; + name: string; + argsBuffer: string; + }; + const openToolCalls = new Map(); + // OpenAI uses index-based tracking before we have a real callId. + const indexToCallId = new Map(); + + if (signal?.aborted) { + yield { + type: "inference.error", + seq: nextSeq(), + data: { error: classifyAbortError(), partial: snapshotPartial(partial) }, + }; + return; + } + + let adapter; + try { + adapter = deps.adapters.resolve(lastCycleSource, source.quirks); + } catch (cause) { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: { + category: "fatal", + message: + cause instanceof Error + ? cause.message + : `Unknown provider: ${lastCycleSource.provider}`, + }, + partial: snapshotPartial(partial), + }, + }; + return; + } + + let builtRequest; + try { + builtRequest = adapter.buildRequest(turns, model, effectiveOptions); + } catch (cause) { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyNetworkError(cause), + partial: snapshotPartial(partial), + }, + }; + return; + } + + // Resolve the full URL and inject credentials. + const url = resolveURL(builtRequest.url, source.baseURL); + const headers = injectCredentials(builtRequest.headers, source); + + // Per-call timeouts. The inactivity timer fires when the harness + // hasn't yielded an event for `inactivityTimeoutMs`; the total timer + // is a wall-clock cap from fetch onwards. We own one AbortController, + // combine its signal with the caller's, and attribute the abort to + // whichever timer fired by checking `timeoutReason` at the catch site. + const inactivityTimeoutMs = + effectiveOptions.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS; + const totalTimeoutMs = + effectiveOptions.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS; + const scheduler = deps.scheduler; + const timeoutAbort = new AbortController(); + let timeoutReason: "inactivity" | "total" | null = null; + let cancelInactivity: (() => void) | null = null; + const armInactivity = () => { + cancelInactivity?.(); + cancelInactivity = scheduler.setTimeout(() => { + timeoutReason = "inactivity"; + timeoutAbort.abort(); + }, inactivityTimeoutMs); + }; + const cancelTotal = scheduler.setTimeout(() => { + timeoutReason = "total"; + timeoutAbort.abort(); + }, totalTimeoutMs); + // Per-timer cancellers are idempotent (the production scheduler's + // canceller wraps `clearTimeout`, which no-ops on a fired timer; the + // test scheduler's canceller flips a `cancelled` flag). Callers may + // invoke `cleanupTimers` exactly once; the `try/finally` around the + // generator body below is the single owner of that lifecycle. + const cleanupTimers = (): void => { + cancelTotal(); + cancelInactivity?.(); + cancelInactivity = null; + }; + // Combined signal: the production code's existing caller-signal + + // our timeout controller, so a fetch implementation that respects + // AbortSignal sees both. `cleanupSignal` removes the abort listeners + // `combineSignals` installs on the caller signal so a long-lived + // caller signal (e.g., a session-scoped controller) does not + // accumulate one un-removed listener per call. + const { signal: fetchSignal, cleanup: cleanupSignal } = combineSignals( + signal, + timeoutAbort.signal, + ); + + try { + let response: Response; + try { + response = await deps.fetch(url, { + method: "POST", + headers, + body: builtRequest.body, + signal: fetchSignal, + }); + } catch (cause) { + if (timeoutReason !== null) { + const thresholdMs = + timeoutReason === "inactivity" ? inactivityTimeoutMs : totalTimeoutMs; + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyTimeoutError(timeoutReason, thresholdMs), + partial: snapshotPartial(partial), + }, + }; + return; + } + if (signal?.aborted) { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyAbortError(), + partial: snapshotPartial(partial), + }, + }; + return; + } + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyNetworkError(cause), + partial: snapshotPartial(partial), + }, + }; + return; + } + + if (!response.ok) { + // Read the body as text once and then try to parse it as JSON. + // Calling `.json()` first and falling back to `.text()` on the + // same response does not work — per WHATWG fetch the body stream + // is locked/disturbed by the first read attempt, so the fallback + // throws `TypeError: body already consumed` and `errorBody` ends + // up `undefined`. Reading text-then-parsing covers both JSON and + // plain-text error bodies in a single pass. + // + // The read is bound to the combined fetch signal so a hostile + // server returning a 4xx/5xx with a body that never terminates + // cannot hang the call past the total-timeout horizon. + let errorBody: unknown; + try { + const text = await awaitWithSignal(response.text(), fetchSignal); + try { + errorBody = JSON.parse(text); + } catch { + errorBody = text; + } + } catch { + errorBody = undefined; + } + const errorMessage = + extractErrorMessage(errorBody) ?? response.statusText; + const retryAfterMs = adapter.extractRetryAfterMs?.(response.headers); + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyHTTPError( + response.status, + errorMessage, + errorBody, + retryAfterMs, + ), + partial: snapshotPartial(partial), + }, + }; + return; + } + + if (response.body === null) { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyNetworkError(new Error("Response body is null")), + partial: snapshotPartial(partial), + }, + }; + 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(); + } + + // 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)) { + // Reset inactivity timer — we just got something from the wire. + armInactivity(); + yield adapter.parseResponse(sseData); + } + }; + + try { + 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 + // caller-initiated cancellation. + const thresholdMs = + timeoutReason === "inactivity" + ? inactivityTimeoutMs + : totalTimeoutMs; + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyTimeoutError(timeoutReason, thresholdMs), + partial: snapshotPartial(partial), + }, + }; + return; + } + if (signal?.aborted) { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyAbortError(), + partial: snapshotPartial(partial), + }, + }; + return; + } + + for (const raw of rawEvents) { + switch (raw.type) { + case "inference.text.delta": { + const idx = requireIndex(raw, "text.delta"); + const existing = blockMap.get(idx); + if (existing === undefined) { + blockMap.set(idx, { kind: "text", text: raw.data.token }); + } else if (existing.kind === "text") { + existing.text += raw.data.token; + } else { + throw new ProtocolMismatchError( + `harness: text.delta at index ${String(idx)} collides with existing ${existing.kind} block`, + raw, + ); + } + // Running concat of all text deltas — backwards + // compatible with consumers that treat `partial.text` as + // "everything the assistant has typed so far," regardless + // of which content block it came from. + partial.text += raw.data.token; + yield { + type: "inference.text.delta", + seq: nextSeq(), + data: { + token: raw.data.token, + partial: snapshotPartial(partial), + index: idx, + }, + }; + break; + } + + case "inference.refusal.delta": { + const idx = requireIndex(raw, "refusal.delta"); + const existing = blockMap.get(idx); + if (existing === undefined) { + blockMap.set(idx, { kind: "refusal", reason: raw.data.token }); + } else if (existing.kind === "refusal") { + existing.reason += raw.data.token; + } else { + throw new ProtocolMismatchError( + `harness: refusal.delta at index ${String(idx)} collides with existing ${existing.kind} block`, + raw, + ); + } + // Re-yield with a fresh seq; the partial snapshot does + // not currently carry a `refusal` field (PartialMessage + // only knows text and thinking today), so the snapshot + // here reflects the surrounding text/thinking state. + // Subscribers needing the running refusal string + // accumulate tokens from the emitted delta events + // themselves, or read the finalized turn's RefusalBlock. + yield { + type: "inference.refusal.delta", + seq: nextSeq(), + data: { + token: raw.data.token, + partial: snapshotPartial(partial), + index: idx, + }, + }; + break; + } + + case "inference.thinking.delta": { + const idx = requireIndex(raw, "thinking.delta"); + const existing = blockMap.get(idx); + if (existing === undefined) { + blockMap.set(idx, { kind: "thinking", text: raw.data.token }); + } else if (existing.kind === "thinking") { + existing.text += raw.data.token; + } else { + throw new ProtocolMismatchError( + `harness: thinking.delta at index ${String(idx)} collides with existing ${existing.kind} block`, + raw, + ); + } + // Running concat of all thinking deltas across every + // thinking block. Under interleaving (thinking@0 "A", + // text@1 "X", thinking@2 "B"), `partial.thinking` ends + // up "AB" — backwards compatible with the pre-per-index + // single-buffer semantics. Consumers needing per-block + // structure walk the finalized turn's `content[]`. + const concat = (partial.thinking ?? "") + raw.data.token; + partial.thinking = concat; + yield { + type: "inference.thinking.delta", + seq: nextSeq(), + data: { + token: raw.data.token, + partial: snapshotPartial(partial), + index: idx, + }, + }; + break; + } + + case "inference.block.signature": { + const idx = requireIndex(raw, "block.signature"); + const existing = blockMap.get(idx); + if (existing === undefined) { + throw new ProtocolMismatchError( + `harness: block.signature at index ${String(idx)} has no preceding block at that index`, + raw, + ); + } + // 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: 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.block.signature", + seq: nextSeq(), + data: { signature: raw.data.signature, index: idx }, + }; + break; + } + + case "inference.citation": { + const citation = raw.data.citation; + const citationIndex = raw.data.index; + if (citationIndex !== undefined) { + let list = citationsByIndex.get(citationIndex); + if (list === undefined) { + list = []; + citationsByIndex.set(citationIndex, list); + } + list.push(citation); + } else { + unindexedCitations.push(citation); + } + yield { + type: "inference.citation", + seq: nextSeq(), + data: + citationIndex !== undefined + ? { citation, index: citationIndex } + : { citation }, + }; + 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); + if (existing !== undefined) { + throw new ProtocolMismatchError( + `harness: thinking.redacted at index ${String(idx)} collides with existing ${existing.kind} block`, + raw, + ); + } + blockMap.set(idx, { + kind: "redacted_thinking", + data: raw.data.redactedThinking.data, + }); + yield { + type: "inference.thinking.redacted", + seq: nextSeq(), + data: { + redactedThinking: raw.data.redactedThinking, + index: idx, + }, + }; + break; + } + + case "inference.tool_call.start": { + const toolIdx = requireIndex(raw, "tool_call.start"); + const { callId, name } = raw.data; + openToolCalls.set(callId, { callId, name, argsBuffer: "" }); + // OpenAI-flavoured adapters synthesize a placeholder + // callId on tool_call.delta events (the real id is only + // present on the start). Key the resolution map on the + // start event's `data.index` so the placeholder the + // delta emits (`String(blockIndex)`) maps back to the + // real id even when `tcDelta.index` is non-zero or + // non-contiguous. + indexToCallId.set(String(toolIdx), callId); + // Anchor the tool_use position in the per-index map. + // The map walk in final assembly will resolve the marker + // via `completedToolCalls` so the tool_use block lands + // in its wire-arrival position relative to text and + // thinking blocks. Collisions with another kind at the + // same index throw, matching the discipline of the + // text/thinking/redacted_thinking branches above — + // distinct kinds cannot share an index without losing + // the per-index ordering guarantee. + const existingAtIdx = blockMap.get(toolIdx); + if (existingAtIdx === undefined) { + blockMap.set(toolIdx, { kind: "tool_use", callId }); + } else if ( + existingAtIdx.kind !== "tool_use" || + existingAtIdx.callId !== callId + ) { + throw new ProtocolMismatchError( + `harness: tool_call.start at index ${String(toolIdx)} collides with existing ${existingAtIdx.kind} block`, + raw, + ); + } + partial.toolCalls = [ + ...(partial.toolCalls ?? []), + { + id: callId, + name, + partialArguments: "", + }, + ]; + yield { + type: "inference.tool_call.start", + seq: nextSeq(), + data: { + callId, + name, + partial: snapshotPartial(partial), + index: toolIdx, + }, + }; + break; + } + + case "inference.tool_call.delta": { + const { callId, argumentFragment } = raw.data; + + // Resolve index-based callId to real callId if we have a mapping. + const resolvedId = indexToCallId.get(callId) ?? callId; + const tc = openToolCalls.get(resolvedId); + if (tc !== undefined) { + tc.argsBuffer += argumentFragment; + // Update partial.toolCalls entry. + if (partial.toolCalls !== undefined) { + for (const ptc of partial.toolCalls) { + if (ptc.id === resolvedId) { + ptc.partialArguments = tc.argsBuffer; + break; + } + } + } + yield { + type: "inference.tool_call.delta", + seq: nextSeq(), + data: { + callId: resolvedId, + argumentFragment, + partial: snapshotPartial(partial), + }, + }; + } + break; + } + + case "inference.image_output": { + const imgIdx = requireIndex(raw, "image_output"); + const existing = blockMap.get(imgIdx); + if (existing === undefined) { + blockMap.set(imgIdx, { kind: "image", image: raw.data.image }); + } else { + // Image blocks are atomic per event (no streaming + // chunks the way text deltas accumulate). A second + // image_output event at the same index, or any + // collision with a different block kind, is a + // protocol violation -- there is no coalesce branch + // for image_output by design. + throw new ProtocolMismatchError( + `harness: image_output at index ${String(imgIdx)} collides with existing ${existing.kind} block`, + raw, + ); + } + // The `partial` snapshot is intentionally not updated: + // images are not streamed, so there is no + // "partial-image" concept to surface to snapshot + // consumers. The atomic event itself is the signal + // that the image has arrived. The forwarded payload + // carries the ImageBlock verbatim; elision (for logs) + // is the consumer's job and is enforced by the + // existing invariant test against `image_output`. + yield { + type: "inference.image_output", + seq: nextSeq(), + data: { image: raw.data.image, index: imgIdx }, + }; + break; + } + + case "inference.code_execution.start": { + const ceIdx = requireIndex(raw, "code_execution.start"); + const existing = blockMap.get(ceIdx); + if (existing === undefined) { + blockMap.set(ceIdx, { + kind: "code_execution_request", + request: raw.data.request, + }); + } else { + // Code-execution request blocks are atomic per + // event in their current form (Gemini delivers the + // full `code` in one part); a `delta` may extend + // the running request below, but the start handler + // never reuses an existing slot. Collision with a + // different kind at the same index is a wire bug. + throw new ProtocolMismatchError( + `harness: code_execution.start at index ${String(ceIdx)} collides with existing ${existing.kind} block`, + raw, + ); + } + yield { + type: "inference.code_execution.start", + seq: nextSeq(), + data: { request: raw.data.request, index: ceIdx }, + }; + break; + } + + case "inference.code_execution.delta": { + // Append a code fragment to the running request at + // the event's index. Gemini does not emit deltas + // (its `executableCode` is atomic), but the type + // system commits to the streaming lifecycle + // (`start -> delta* -> result`), so the handler is + // wired for providers that do chunk source code. The + // per-index router resolves the target block via + // the event's `index`; the `requestId` is then + // verified against the block's stored id as a + // consistency check that the routed block matches + // the back-pointer the delta carries (a mismatch + // would mean an upstream rerouting bug producing a + // confidently-wrong concatenation). + const ceIdx = requireIndex(raw, "code_execution.delta"); + const existing = blockMap.get(ceIdx); + if (existing === undefined) { + throw new ProtocolMismatchError( + `harness: code_execution.delta at index ${String(ceIdx)} with no preceding code_execution.start`, + raw, + ); + } + if (existing.kind !== "code_execution_request") { + throw new ProtocolMismatchError( + `harness: code_execution.delta at index ${String(ceIdx)} routed to a ${existing.kind} block`, + raw, + ); + } + if (existing.request.id !== raw.data.requestId) { + throw new ProtocolMismatchError( + `harness: code_execution.delta requestId ${JSON.stringify(raw.data.requestId)} does not match the block's request id ${JSON.stringify(existing.request.id)} at index ${String(ceIdx)}`, + raw, + ); + } + existing.request = { + ...existing.request, + code: existing.request.code + raw.data.codeFragment, + }; + yield { + type: "inference.code_execution.delta", + seq: nextSeq(), + data: { + requestId: raw.data.requestId, + codeFragment: raw.data.codeFragment, + index: ceIdx, + }, + }; + break; + } + + case "inference.code_execution.result": { + const ceIdx = requireIndex(raw, "code_execution.result"); + const existing = blockMap.get(ceIdx); + if (existing === undefined) { + blockMap.set(ceIdx, { + kind: "code_execution_result", + result: raw.data.result, + }); + } else { + throw new ProtocolMismatchError( + `harness: code_execution.result at index ${String(ceIdx)} collides with existing ${existing.kind} block`, + raw, + ); + } + yield { + type: "inference.code_execution.result", + seq: nextSeq(), + data: { result: raw.data.result, index: ceIdx }, + }; + break; + } + + case "inference.usage": { + // Accumulate usage — providers may send multiple usage events + // (e.g., Anthropic sends one at message_start with input + // tokens, then one at message_delta with output tokens + // and input deliberately set to 0 by the parser to mean + // "no change to input"). Emit the cumulative + // post-merge total rather than the raw incoming so + // downstream consumers and invariants see a monotone + // non-decreasing stream — the raw incoming would + // observably "decrease" input from a real count back + // to 0 between the two events even though no decrease + // occurred in the underlying counter. + // + // The source field uses the call-start `lastCycleSource` + // snapshot rather than `raw.data.source`. The adapter + // stamps source on its own emit because the InferenceEvent + // type requires the field at every producer site, but the + // harness owns identity attribution for downstream + // consumers: the harness's snapshot is the single source + // of truth, the adapter's stamp is type-system overhead + // that gets replaced here. Both descriptors are equal by + // construction (the registry passes the same snapshot to + // the adapter factory), so the override is redundant for + // correctness; it exists so a future provider that + // synthesizes its own descriptor cannot drift from the + // call-start identity the rest of the harness commits to. + usageSeen = mergeUsage(usageSeen, raw.data.usage); + yield { + type: "inference.usage", + seq: nextSeq(), + data: { usage: usageSeen, source: lastCycleSource }, + }; + break; + } + + // inference.done and inference.error from adapters are unexpected — + // the harness emits those itself. Ignore them. + default: + break; + } + } + } + } catch (cause) { + if (timeoutReason !== null) { + const thresholdMs = + timeoutReason === "inactivity" ? inactivityTimeoutMs : totalTimeoutMs; + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyTimeoutError(timeoutReason, thresholdMs), + partial: snapshotPartial(partial), + }, + }; + return; + } + if (signal?.aborted) { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyAbortError(), + partial: snapshotPartial(partial), + }, + }; + return; + } + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: classifyStreamError(cause), + partial: snapshotPartial(partial), + }, + }; + return; + } + + // Finalize any open tool calls that never received an explicit end event. + const completedToolCalls: ContentBlock[] = []; + for (const tc of openToolCalls.values()) { + let parsedArgs: Record; + try { + const raw = tc.argsBuffer.trim() === "" ? "{}" : tc.argsBuffer; + const parsed = JSON.parse(raw); + const validated = ParsedToolArgs(parsed); + parsedArgs = validated instanceof type.errors ? {} : validated; + } catch { + parsedArgs = { _raw: tc.argsBuffer }; + } + + completedToolCalls.push({ + type: "tool_call", + id: tc.callId, + name: tc.name, + arguments: parsedArgs, + }); + + yield { + type: "inference.tool_call.end", + seq: nextSeq(), + data: { + callId: tc.callId, + name: tc.name, + arguments: parsedArgs, + partial: snapshotPartial(partial), + }, + }; + } + + const finalUsage: TokenUsage = usageSeen ?? { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }; + + // Emit inference.usage before inference.done per the protocol spec. + if (usageSeen === null) { + yield { + type: "inference.usage", + seq: nextSeq(), + data: { usage: finalUsage, source: lastCycleSource }, + }; + } + + // Build the final assistant message by walking the per-index map + // in insertion order. JS `Map` preserves insertion order for all + // keys (including integers — distinct from plain object behaviour), + // so iteration here reproduces the wire-arrival order of content + // blocks regardless of the numeric values. Tool-call markers are + // resolved to the finalized ContentBlock from the completedToolCalls + // array via the marker's callId. + const completedToolCallsByCallId = new Map(); + for (const tc of completedToolCalls) { + if (tc.type === "tool_call") { + completedToolCallsByCallId.set(tc.id, tc); + } + } + const contentBlocks: ContentBlock[] = []; + // Emit a content block and immediately append (and consume) any + // citations registered at that block's index. Centralizing the + // per-emission interleave step here means each arm of the walk + // below just calls `emit(block, idx)`; a new block kind can't + // forget the interleave step. Consumed indices are deleted from + // `citationsByIndex` so the post-walk check below can detect any + // citation whose index pointed at a block that never emitted + // (orphan reference or block filtered out during finalization) + // and surface it loudly rather than silently dropping the + // citation from `content[]`. + const emit = (block: ContentBlock, idx: number) => { + contentBlocks.push(block); + const atIdx = citationsByIndex.get(idx); + if (atIdx !== undefined) { + contentBlocks.push(...atIdx); + citationsByIndex.delete(idx); + } + }; + for (const [idx, entry] of blockMap.entries()) { + if (entry.kind === "text") { + // 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") { + // Emit thinking blocks even when text is empty if a signature + // was captured — Anthropic's redacted-adjacent flow can + // produce a thinking block whose visible text is empty but + // whose signature must round-trip on follow-up turns. + if (entry.text.length === 0 && entry.signature === undefined) { + continue; + } + emit( + { + type: "thinking", + thinking: entry.text, + ...(entry.signature !== undefined + ? { signature: entry.signature } + : {}), + }, + idx, + ); + continue; + } + if (entry.kind === "redacted_thinking") { + emit({ type: "redacted_thinking", data: entry.data }, idx); + continue; + } + if (entry.kind === "refusal") { + // Empty-reason refusals were filtered at the adapter's wire + // boundary (the OpenAI parser skips delta.refusal chunks with + // length 0), so an entry that reaches the final walk with an + // empty reason indicates either a synthetic capture or a + // future adapter without that guard. Skip rather than emit a + // RefusalBlock with reason: "" which would fail the type's + // documented "human-readable text the model emitted" contract. + if (entry.reason.length === 0) continue; + emit({ type: "refusal", reason: entry.reason }, idx); + continue; + } + if (entry.kind === "tool_use") { + const finalized = completedToolCallsByCallId.get(entry.callId); + if (finalized === undefined) { + // Every tool_use marker is added in the + // inference.tool_call.start handler at the same time the + // entry is inserted into openToolCalls. The finalize loop + // above turns every openToolCalls entry into a + // completedToolCalls entry. So a marker whose callId is + // missing from completedToolCallsByCallId here would mean + // the start-time bookkeeping diverged from the finalize- + // time bookkeeping — surface it loudly rather than dropping + // the tool call from the final turn. + throw new ProtocolMismatchError( + `harness: tool_use marker at callId ${entry.callId} has no matching completed tool call`, + entry, + ); + } + 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") { + // Image blocks land here when an adapter delivered an + // `inference.image_output` event at this index. The + // ImageBlock is stored complete on the entry (images are + // atomic, not streamed), so the final-walk emits it + // verbatim. Citation interleave applies the same way as + // any other block kind. + emit( + entry.signature !== undefined + ? { ...entry.image, signature: entry.signature } + : entry.image, + idx, + ); + continue; + } + if (entry.kind === "code_execution_request") { + // The request block carries whatever code accumulated + // across `code_execution.start` plus any subsequent + // `code_execution.delta` events at this index. Gemini's + // 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.signature !== undefined + ? { ...entry.request, signature: entry.signature } + : entry.request, + idx, + ); + continue; + } + if (entry.kind === "code_execution_result") { + emit(entry.result, idx); + continue; + } + entry satisfies never; + } + if (citationsByIndex.size > 0) { + // A citation whose `index` pointed at a block that never made + // it into `content[]` would otherwise be silently dropped. The + // cases that get here in practice are upstream bugs: an adapter + // emitted a citation indexed at a block that doesn't exist, or + // at a block that the finalize walk filtered out (empty text, + // empty thinking with no signature). Surface the bookkeeping + // mismatch loudly rather than papering over it. + const orphanIndices = Array.from(citationsByIndex.keys()).sort( + (a, b) => a - b, + ); + throw new ProtocolMismatchError( + `harness: ${String(citationsByIndex.size)} citation index/indices have no matching emitted block in the final turn: ${orphanIndices.join(", ")}`, + { orphanIndices }, + ); + } + contentBlocks.push(...unindexedCitations); + contentBlocks.push(...unindexedSafetyRatings); + + const finalTurn: AssistantTurn = { + role: "assistant", + content: contentBlocks, + model, + timestamp: Date.now(), + }; + + const pacingDelayMs = adapter.extractPacingDelayMs?.(response.headers); + + yield { + type: "inference.done", + seq: nextSeq(), + data: { + turn: finalTurn, + usage: finalUsage, + source: lastCycleSource, + ...(pacingDelayMs !== undefined && pacingDelayMs > 0 + ? { pacingDelayMs } + : {}), + }, + }; + } finally { + // Single owner of the timer + signal-listener lifecycle. Runs on + // every exit including normal completion, early `return`, thrown + // errors, and consumer abandonment via `for await` `break` + // (which invokes the generator's `return()` and triggers the + // finally). Both cleanups are idempotent. + cleanupTimers(); + cleanupSignal(); + } +} + +/** + * Run a single inference call with mechanical retry. Wraps + * `runSingleAttempt` and consults the configured `RetryPolicy` (or the + * default from `createDefaultRetryPolicy`) on every `inference.error`. + * + * Events from each attempt are buffered until the attempt terminates; + * the wrapper only flushes them to the caller once it knows whether + * the attempt resolved (`inference.done` or a policy-approved abort) + * or whether the attempt's events should be discarded in favour of a + * retry. The buffer-and-flush model is what guarantees the caller + * sees a single clean event stream — exactly one `inference.start`, + * no orphaned partial deltas, no leaked `inference.error`s from + * attempts the policy chose to retry. The cost is that no events + * reach the caller until the wrapper knows the attempt's terminal + * shape, even on a successful first attempt. That trade-off is the + * deliberate consequence of making "one clean stream" a hard contract + * rather than a best-effort one. Consumers that need token-by-token + * partials must pin a custom non-buffering wrapper — no streaming- + * partials emission API exists today. + * + * The buffer is per-call and bounded by the size of one attempt's + * event stream — no cross-call accumulation. + * + * Caller-visible seqs stay contiguous across retries. Each attempt + * runs against a private seq allocator; on flush the wrapper + * re-stamps the buffered events with seqs from the caller's + * `nextSeq`, so a retry that discards an attempt does not leave a + * gap in the consumer's seq stream. + * + * Between attempts the wrapper emits one `inference.retry` event with + * the failed attempt's number, the policy-chosen `delayMs`, and the + * classified error that triggered the retry. The `setTimeout` await + * is driven by `deps.scheduler`, so virtual-clock test harnesses + * advance retry delays without sleeping real wall-clock. The + * caller-supplied `signal` short-circuits the retry delay: aborting + * the signal mid-delay wakes the await immediately and the next + * `runSingleAttempt` invocation surfaces `inference.error` of + * category `aborted` from its entry-time signal check, which the + * default policy aborts on. + * + * Policy-failure handling: if the policy throws synchronously or its + * returned Promise rejects, the wrapper treats the failure as + * `{ kind: "abort" }` and surfaces the *original* `inference.error` + * to the caller. The policy's own exception is logged at `warn` so + * operators can see when a custom policy is failing under load, and + * dropped — the inference error is what the caller needs to act on, + * not the bug in the policy callback. + * + * Synchronous throws from `runSingleAttempt` (`ProtocolMismatchError` + * raised by the streaming parse or the finalization walk, etc.) + * propagate out of `runInference`. The current attempt's buffered + * events are discarded along with the throw — those represent + * protocol bugs the policy mechanism is not equipped to absorb, and + * the caller's `for await` rejects so the failure surfaces rather + * than being silently buffered. + */ +export async function* runInference( + opts: InferenceHarnessOptions, +): AsyncIterable { + // Crash-loudly guards. The wrapper and the attempts it drives access + // `deps.fetch` and `deps.adapters` (per `runSingleAttempt` invocation) + // and `deps.scheduler` (read here for the monotonic time source) before + // any event yields. A malformed `deps` from a JS caller would otherwise + // surface as a confusing `Cannot read properties of undefined`. The + // wrapper is the single public entrypoint to the harness; this is the + // right layer to own the `deps` shape check. + if (typeof opts.deps?.fetch !== "function") { + throw new Error( + `runInference: deps.fetch must be a function (got ${typeof opts.deps?.fetch}); pass createDefaultDependencies() or a test harness Dependencies object`, + ); + } + if (typeof opts.deps.scheduler?.now !== "function") { + const schedulerType = typeof opts.deps.scheduler; + const detail = + schedulerType === "object" + ? "scheduler is missing the now() method" + : `got ${schedulerType}`; + throw new Error( + `runInference: deps.scheduler must implement now() (${detail}); pass createDefaultDependencies() or a test harness Dependencies object`, + ); + } + if (typeof opts.deps.adapters?.resolve !== "function") { + const adaptersType = typeof opts.deps.adapters; + const detail = + adaptersType === "object" + ? "adapters is missing the resolve() method" + : `got ${adaptersType}`; + throw new Error( + `runInference: deps.adapters must implement resolve() (${detail}); pass createDependencies(adapters), createDefaultDependencies(), or a test harness Dependencies object`, + ); + } + const policy = + opts.inferenceOptions?.retryPolicy ?? createDefaultRetryPolicy(); + // The guards above proved `opts.deps.scheduler` is well-formed; the + // rest of the wrapper reads it directly without the `?.` ceremony. + const scheduler = opts.deps.scheduler; + const startedAtMs = scheduler.now(); + const signal = opts.signal; + + for (let attempt = 1; ; attempt++) { + const buffered: InferenceEvent[] = []; + let terminalError: InferenceError | undefined; + + // Per-attempt private allocator. `runSingleAttempt` allocates a + // seq for every event it yields; if the attempt is discarded on + // retry, any caller-visible seq it had consumed would leave a + // gap in the consumer's stream — indistinguishable from the + // "missed events during brief disconnection" the seq stream is + // documented to expose. Allocate from a private counter here and + // re-stamp the buffer with caller-visible seqs at flush time. + let attemptSeq = 0; + const attemptOpts: InferenceHarnessOptions = { + ...opts, + nextSeq: () => attemptSeq++, + }; + for await (const event of runSingleAttempt(attemptOpts)) { + buffered.push(event); + if (event.type === "inference.error") { + terminalError = event.data.error; + break; + } + if (event.type === "inference.done") { + break; + } + } + + if (terminalError === undefined) { + // Successful attempt. Re-stamp the buffer with caller-visible + // seqs (the private allocator's values are discarded) and + // flush in order. + for (const event of buffered) yield { ...event, seq: opts.nextSeq() }; + return; + } + + // Consult the policy. Sync throws and Promise rejections both + // resolve to an abort decision; the original inference.error + // surfaces to the caller, not the policy's exception. The + // exception is logged at warn so a custom policy that + // misbehaves under load is not invisible — swallowing the + // failure silently would hide the bug from operators. + let decision: RetryDecision; + try { + decision = await Promise.resolve( + policy({ + error: terminalError, + attempt, + elapsedMs: scheduler.now() - startedAtMs, + }), + ); + } catch (cause) { + logger.warn`Retry policy threw at attempt ${String(attempt)}; treating as abort. error=${cause instanceof Error ? cause.message : String(cause)}`; + decision = { kind: "abort" }; + } + + if (decision.kind === "abort") { + // Flush the buffer (including the terminal inference.error) + // with re-stamped caller-visible seqs and return. No + // `inference.retry` event is emitted on the abort path. + for (const event of buffered) yield { ...event, seq: opts.nextSeq() }; + return; + } + + // Retry: discard the failed attempt's events, emit a single + // inference.retry, await the delay, and re-enter the loop. + yield { + type: "inference.retry", + seq: opts.nextSeq(), + data: { + attempt, + delayMs: decision.delayMs, + previousError: terminalError, + }, + }; + + const retryDelayMs = decision.delayMs; + // Wire the caller-supplied signal into the delay so an abort + // during the wait short-circuits to the next attempt within a + // single virtual tick rather than blocking until the full + // `retryDelayMs` elapses. A 60-second `retryAfterMs` on a quota + // error would otherwise pin the wrapper for the full minute + // before honouring cancellation. The shape is the standard one + // for racing a scheduled timeout against an abort listener: a + // single `settled` flag plus a `settle()` helper that cancels + // whichever side did not fire and removes the listener so the + // caller signal does not accumulate one stale entry per call. + await new Promise((resolve) => { + let settled = false; + const settle = (): void => { + if (settled) return; + settled = true; + cancelTimer(); + if (signal !== undefined) { + signal.removeEventListener("abort", onAbort); + } + resolve(); + }; + const onAbort = (): void => { + settle(); + }; + const cancelTimer = scheduler.setTimeout(() => { + settle(); + }, retryDelayMs); + if (signal !== undefined) { + if (signal.aborted) { + settle(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + }); + } +} + +/** + * Combine an optional caller-supplied `AbortSignal` with the harness's + * internal timeout-driven controller into a single signal the fetch + * implementation can observe. Returns the internal controller's signal + * alone if no caller signal exists; otherwise wires both so that either + * one firing aborts the combined signal. + * + * Returns a bundle containing the signal AND an explicit cleanup + * function. `{ once: true }` on the abort listeners only auto-removes + * after firing, so on the happy path (no abort) the listeners would + * accumulate against a long-lived caller signal — one un-removed + * listener per `runInference` call. The caller MUST invoke + * `cleanup()` exactly once when the call's interest in the signal + * ends (whether by completion, error, or abandonment); the harness + * does this from its `try/finally` block. `cleanup()` is idempotent. + */ +type CombinedSignal = { + readonly signal: AbortSignal; + readonly cleanup: () => void; +}; + +function combineSignals( + caller: AbortSignal | undefined, + internal: AbortSignal, +): CombinedSignal { + if (caller === undefined) { + const noopCleanup = (): void => { + /* no listener was attached */ + }; + return { signal: internal, cleanup: noopCleanup }; + } + const composite = new AbortController(); + const onCallerAbort = (): void => { + composite.abort(caller.reason); + }; + const onInternalAbort = (): void => { + composite.abort(internal.reason); + }; + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) return; + cleanedUp = true; + caller.removeEventListener("abort", onCallerAbort); + internal.removeEventListener("abort", onInternalAbort); + }; + if (caller.aborted) { + composite.abort(caller.reason); + } else { + caller.addEventListener("abort", onCallerAbort, { once: true }); + } + if (internal.aborted) { + composite.abort(internal.reason); + } else { + internal.addEventListener("abort", onInternalAbort, { once: true }); + } + return { signal: composite.signal, cleanup }; +} + +/** + * Await `promise` but reject early if `signal` aborts in the meantime. + * Used for non-streaming reads of the error response body so a hostile + * server cannot hang the call by returning a 4xx/5xx with a body that + * never terminates. The signal's listener is always removed before + * settlement so this helper does not itself leak listeners. + */ +async function awaitWithSignal( + promise: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + throw new DOMException("aborted", "AbortError"); + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + reject(new DOMException("aborted", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (err: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(err instanceof Error ? err : new Error(String(err))); + }, + ); + }); +} + +function snapshotPartial(partial: PartialMessage): PartialMessage { + return { + text: partial.text, + ...(partial.thinking !== undefined ? { thinking: partial.thinking } : {}), + ...(partial.toolCalls !== undefined + ? { + toolCalls: partial.toolCalls.map((tc) => ({ + id: tc.id, + name: tc.name, + partialArguments: tc.partialArguments, + })), + } + : {}), + }; +} + +// The harness's per-index routing is load-bearing on every delta +// carrying an `index`. Provider adapters synthesize a default at the +// adapter boundary if their wire shape doesn't carry one (e.g. +// OpenAI Chat Completions emits `index: 0` explicitly on text and +// thinking deltas because Chat Completions ships a single content +// block per kind per response). A delta arriving at the harness +// without an index is a wiring bug at the adapter, not data the +// harness should silently route to block 0 — surfacing it as a +// ProtocolMismatchError is the load-bearing alternative to corrupt +// state. +function requireIndex( + event: { + type: string; + data: { index?: number }; + }, + variant: string, +): number { + const index = event.data.index; + if (index === undefined) { + throw new ProtocolMismatchError( + `harness received ${event.type} (${variant}) without an index; ` + + `provider adapters must synthesize an index at the boundary even ` + + `when the wire shape doesn't carry one`, + event, + ); + } + return index; +} + +function mergeUsage( + existing: TokenUsage | null, + incoming: TokenUsage, +): TokenUsage { + if (existing === null) return incoming; + return { + input: existing.input + incoming.input, + output: existing.output + incoming.output, + cacheRead: existing.cacheRead + incoming.cacheRead, + cacheWrite: existing.cacheWrite + incoming.cacheWrite, + thinking: existing.thinking + incoming.thinking, + }; +} + +function resolveURL(path: string, baseURL: string): string { + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + const base = baseURL.endsWith("/") ? baseURL.slice(0, -1) : baseURL; + return base + path; +} + +const ParsedToolArgs = type("Record"); + +const ErrorBody = type({ error: { message: "string" } }); +const DirectMessageBody = type({ message: "string" }); + +/** + * Upper bound on the length of a plain-text error body that gets + * promoted to `InferenceError.message`. Bodies longer than this are + * truncated with a marker pointing operators at `error.raw`, which + * always retains the untruncated body. Structured JSON envelopes are + * not subject to this cap — their `message` fields are server-curated + * and concise in practice. + * + * 500 characters covers a multi-line stack trace or a paragraph of + * diagnostic text without blowing up the default director's + * user-facing reply (which concatenates the message into a chat-style + * string) or the timeline part stored by the hub event collector. + */ +const MAX_PLAIN_TEXT_MESSAGE_CHARS = 500; + +function truncatePlainTextMessage(text: string): string { + if (text.length <= MAX_PLAIN_TEXT_MESSAGE_CHARS) return text; + return `${text.slice(0, MAX_PLAIN_TEXT_MESSAGE_CHARS)}… (truncated; full body in error.raw)`; +} + +function extractErrorMessage(body: unknown): string | null { + // Anthropic/OpenAI: { error: { message: "..." } } + const errorBody = ErrorBody(body); + if (!(errorBody instanceof type.errors)) { + return errorBody.error.message; + } + + // Direct message field as fallback. + const directBody = DirectMessageBody(body); + if (!(directBody instanceof type.errors)) { + return directBody.message; + } + + // Plain-text error bodies (HTML error pages, raw exception strings, + // load-balancer diagnostics). The body reaches us via the + // text-then-parse path in the `!response.ok` branch: when + // JSON.parse failed, the raw string is stored as errorBody. + // Surfacing it here means the operator-visible message contains + // the server's actual diagnostic rather than just `statusText`. + // `error.raw` always holds the untruncated body for audit-time + // inspection. + if (typeof body === "string" && body.length > 0) { + return truncatePlainTextMessage(body); + } + + return null; +} diff --git a/vendor/intx/inference/src/index.ts b/vendor/intx/inference/src/index.ts new file mode 100644 index 000000000..bd19dfd23 --- /dev/null +++ b/vendor/intx/inference/src/index.ts @@ -0,0 +1,74 @@ +export { parseSSE } from "./sse"; +export { encodeToolName, decodeToolName } from "./tool-name"; +export type { ToolNameLimit } from "./tool-name"; +export { + runInference, + createDependencies, + createDefaultScheduler, + HarnessId, +} from "./harness"; +export type { + Dependencies, + InferenceHarnessOptions, + Scheduler, +} from "./harness"; +export type { + ProviderAdapter, + RequestBuilder, + ResponseParser, + BuiltRequest, + AdapterRegistry, + AdapterFactory, +} from "./adapter"; +export { AdapterManifest, AdapterManifestEntry } from "./manifest"; +export { CREDENTIAL_SENTINEL, BEARER_CREDENTIAL_SENTINEL } from "./auth"; +export { + classifyHTTPError, + classifyNetworkError, + classifyAbortError, + classifyStreamError, + classifyProtocolMismatch, + ProtocolMismatchError, +} from "./errors"; +export { transformMessages, createIDNormalizer } from "./transform"; +export type { TransformOptions, IDNormalizer } from "./transform"; +export { createDefaultRetryPolicy } from "./retry-policy"; +export type { + RetryPolicy, + RetrySituation, + RetryDecision, +} from "@intx/types/runtime"; +export { uploadGoogleGenAIFile } from "./providers/google-genai-files"; +export type { + UploadGoogleGenAIFileOpts, + UploadGoogleGenAIFileFetch, + UploadedGoogleGenAIFile, +} from "./providers/google-genai-files"; + +export { createInboundTurn, assertWellFormedToolSequence } from "./turns"; +export { createReactor } from "./reactor"; +export type { Reactor, ReactorConfig, ReactorEmittedEvent } from "./reactor"; +export { validateActions } from "./actions"; +export type { ValidationResult } from "./actions"; +export { createGateManager } from "./gates"; +export type { GateManager, GateRecord, GateSnapshot } from "./gates"; +export { createCorrelationRegistry } from "./correlation"; +export type { CorrelationRegistry, CorrelationValidator } from "./correlation"; +export { createStateManager } from "./state"; +export type { ReactorStateManager } from "./state"; +export { createCapabilities } from "./director"; +export { DefaultDirector, createDefaultDirector } from "./default-director"; +export type { DefaultDirectorPolicy } from "./default-director"; +export { createAuthzExtension } from "./authz-extension"; +export type { + AuthzCallResult, + AuthzDecision, + AuthzExtensionOptions, + AuthzMatchedGrant, +} from "./authz-extension"; +export { createAuditCollector } from "./audit-collector"; +export type { AuditCollector } from "./audit-collector"; +export { createSizeCapTransform } from "./transforms"; +export type { SizeCapTransformOptions } from "./transforms"; +export { createReactorAssembly } from "./assembly"; +export type { ReactorAssembly, ReactorAssemblyConfig } from "./assembly"; diff --git a/vendor/intx/inference/src/manifest.ts b/vendor/intx/inference/src/manifest.ts new file mode 100644 index 000000000..00666fc06 --- /dev/null +++ b/vendor/intx/inference/src/manifest.ts @@ -0,0 +1,66 @@ +import { type } from "arktype"; +import type { AdapterFactory } from "./adapter"; + +// Describes one custom adapter: the provider identifier it serves, the module +// specifier to import, and the named export within that module to use as its +// factory. Specifiers are operator-config-only and resolve to arbitrary code +// via `import()`, so they must originate solely from trusted operator +// configuration, never from tenant or deploy data. The shape is validated at +// every deserialization boundary; the value is trusted operator input. +export const AdapterManifestEntry = type({ + provider: "string", + specifier: "string", + export: "string", +}); +export type AdapterManifestEntry = typeof AdapterManifestEntry.infer; + +export const AdapterManifest = AdapterManifestEntry.array(); +export type AdapterManifest = typeof AdapterManifest.infer; + +// Imports a module by specifier. The production importer is `import()`; tests +// inject a synthetic importer so they can exercise the loader without fixture +// modules on disk. +export type ModuleImporter = (specifier: string) => Promise; + +/** + * Imports each manifest entry's module and narrows its named export to an + * {@link AdapterFactory}, returning a record keyed by provider identifier. + * Later entries override earlier ones sharing a provider key. + * + * Fails loud (naming the specifier, and the export where relevant) when a + * module does not resolve to an object, the named export is missing, or the + * named export is not a function. The importer seam defaults to `import()` and + * is injectable for testing. + * + * @param manifest - Validated manifest entries to load + * @param opts - Optional injected module importer + * @returns A record of provider identifier to adapter factory + */ +export async function loadAdapterFactories( + manifest: AdapterManifest, + opts?: { import?: ModuleImporter }, +): Promise> { + const importer = opts?.import ?? ((specifier: string) => import(specifier)); + const factories: Record = {}; + + for (const entry of manifest) { + const mod = await importer(entry.specifier); + if (typeof mod !== "object" || mod === null) { + throw new Error( + `Adapter module did not resolve to an object: ${entry.specifier}`, + ); + } + + const exported: unknown = Reflect.get(mod, entry.export); + if (typeof exported !== "function") { + throw new Error( + `Adapter export is not a function: ${entry.export} from ${entry.specifier}`, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- dynamically imported factory; the call signature cannot be verified at runtime, enforced by the AdapterFactory contract + factories[entry.provider] = exported as AdapterFactory; + } + + return factories; +} diff --git a/vendor/intx/inference/src/providers/anthropic.ts b/vendor/intx/inference/src/providers/anthropic.ts new file mode 100644 index 000000000..4db92e31a --- /dev/null +++ b/vendor/intx/inference/src/providers/anthropic.ts @@ -0,0 +1,1134 @@ +import { type } from "arktype"; + +import type { + ConversationTurn, + ContentBlock, + InferenceEvent, + InferenceOptions, + LastCycleSource, + MediaSource, + PartialMessage, + TokenUsage, +} 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"; +import { + decodeToolName, + encodeToolName, + type ToolNameLimit, +} from "../tool-name"; + +// Anthropic's tool-name constraint is `^[a-zA-Z0-9_-]{1,128}$`, which rejects +// the raw package-qualified names for the same out-of-charset characters as +// the OpenAI family. +const ANTHROPIC_TOOL_NAME_LIMIT: ToolNameLimit = { + provider: "anthropic", + maxLength: 128, +}; + +// Models that reject thinking:{type:"enabled",budget_tokens} and require +// thinking:{type:"adaptive"} with output_config.effort. The discovery +// plug-in's ADAPTIVE_THINKING_MODELS set must match this one; a guard test in +// the anthropic discovery package pins the two equal so they cannot drift. +export const ADAPTIVE_THINKING_MODELS: ReadonlySet = new Set([ + "claude-sonnet-5", + "claude-opus-5", + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-sonnet-4-6", +]); + +// The effort this adapter sends on the adaptive-thinking wire in production. +// "high" is the Anthropic API default. The discovery capture rig deliberately +// sends "max" instead: only "max" reliably elicits a thinking block to capture, +// so the production default and the capture value are an intentional pair, not +// drift. ADAPTIVE_THINKING_MODELS above must match across the two layers; the +// effort values, by contrast, are meant to differ. A guard test in the +// discovery package checks both effort values. +export const ADAPTIVE_THINKING_EFFORT = "high"; + +// --------------------------------------------------------------------------- +// Request building +// --------------------------------------------------------------------------- + +function buildRequest( + messages: ConversationTurn[], + model: string, + options: InferenceOptions, +): BuiltRequest { + rejectUnsupportedResponseFormat(options.responseFormat); + const systemMessages = messages.filter((m) => m.role === "system"); + const conversationMessages = messages.filter((m) => m.role !== "system"); + + const systemText = systemMessages + .flatMap((m) => + m.content + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text), + ) + .join("\n\n"); + + const effectiveSystem = options.systemPrompt + ? options.systemPrompt + : systemText || undefined; + + const body: Record = { + model, + max_tokens: options.maxTokens ?? 4096, + messages: conversationMessages.map((msg, i) => { + // Place a cache breakpoint on the last user message so all prior + // turns are cached on the next request. + const isLastUser = + msg.role !== "assistant" && + conversationMessages.slice(i + 1).every((m) => m.role === "assistant"); + return toAnthropicMessage(msg, isLastUser); + }), + stream: true, + }; + + if (effectiveSystem) { + body["system"] = [ + { + type: "text", + text: effectiveSystem, + cache_control: { type: "ephemeral" }, + }, + ]; + } + + if (options.thinking?.enabled) { + // 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: ADAPTIVE_THINKING_EFFORT }; + } else { + body["thinking"] = { + type: "enabled", + budget_tokens: options.thinking.budgetTokens ?? 1024, + }; + } + } + + if (options.tools !== undefined && options.tools.length > 0) { + const tools: Record[] = options.tools.map((t) => ({ + name: encodeToolName(t.name, ANTHROPIC_TOOL_NAME_LIMIT), + description: t.description, + input_schema: t.inputSchema, + })); + const lastTool = tools[tools.length - 1]; + if (lastTool !== undefined) { + lastTool["cache_control"] = { type: "ephemeral" }; + } + body["tools"] = tools; + } + + if (options.temperature !== undefined) { + body["temperature"] = options.temperature; + } + + return { + url: "/v1/messages", + headers: { + "content-type": "application/json", + "x-api-key": CREDENTIAL_SENTINEL, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(body), + }; +} + +// Anthropic's Messages API has no native structured-outputs surface. +// `text` is the default and a no-op. `json` and `json-schema` raise +// here at the marshaling boundary rather than silently dropping the +// field; the codebase prefers loud failure at the wire boundary over +// a forward-synthesis shim (a hidden tool whose input_schema mirrors +// the requested schema) because no other adapter shim synthesizes +// requests the caller didn't author. +function rejectUnsupportedResponseFormat( + format: InferenceOptions["responseFormat"], +): void { + if (format === undefined) return; + if (format.kind === "text") return; + throw new Error( + `Anthropic adapter does not support structured outputs ` + + `(responseFormat.kind="${format.kind}").`, + ); +} + +function toAnthropicMessage( + msg: ConversationTurn, + cacheLastBlock?: boolean, +): Record { + const role = msg.role === "assistant" ? "assistant" : "user"; + // 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) { + lastBlock["cache_control"] = { type: "ephemeral" }; + } + } + return { role, content }; +} + +// Marshal a MediaSource into Anthropic's nested `source` shape. +// Base64 sources carry the mimeType on the wire as `media_type`. +// File-reference and URL sources carry no mimeType: Anthropic +// identifies file-reference content by id alone (encoded server-side +// at upload time) and infers URL-sourced content from the response of +// the fetch it performs. The MediaSource's mimeType is intentionally +// dropped at this layer for both. The internal `mimeType` requirement +// on the non-base64 variants keeps callers honest about what they +// have in hand even when the provider doesn't need it. +function toAnthropicMediaSource(source: MediaSource): Record { + if (source.kind === "base64") { + return { + type: "base64", + media_type: source.mimeType, + data: source.data, + }; + } + if (source.kind === "file-reference") { + return { + type: "file", + file_id: source.reference, + }; + } + if (source.kind === "url") { + return { + type: "url", + url: source.url, + }; + } + // Exhaustiveness: a new MediaSource variant added without a case + // here fails this compile-time check. + source satisfies never; + throw new Error(`unreachable: unknown MediaSource kind`); +} + +// Map an Anthropic-streamed citation onto the internal CitationBlock. +// `textOffset` is intentionally not populated for any variant: +// Anthropic's offsets are document-relative (page numbers, doc char +// offsets, doc block indices) rather than text-relative — they don't +// correspond to UTF-16 positions in the preceding TextBlock that +// `CitationBlock.textOffset` describes. Computing text-relative +// offsets from `cited_text` substring search produces wrong answers +// whenever `cited_text` is paraphrased, appears multiple times, or +// spans wire-chunk boundaries; better to leave the field unset than +// guess. +// +// `encrypted_index` (web_search_result_location) has no echo-back +// target in CitationBlock today and is intentionally dropped at this +// layer. When echo-back of citation context lands, this is the layer +// to preserve it from. +function toCitationBlock( + wire: typeof AnthropicCitation.infer, + index: number, +): typeof CitationBlockType.infer { + if (wire.cited_text === undefined) { + throw new ProtocolMismatchError( + `anthropic parseResponse: citation at block ${index} missing required \`cited_text\``, + wire, + ); + } + const citedText = wire.cited_text; + const source: { + title?: string; + uri?: string; + documentRef?: { index: number }; + } = {}; + if (wire.title !== undefined) source.title = wire.title; + if (wire.url !== undefined) source.uri = wire.url; + if (wire.document_title !== undefined && source.title === undefined) { + source.title = wire.document_title; + } + if (wire.document_index !== undefined) { + source.documentRef = { index: wire.document_index }; + } + + switch (wire.type) { + case "web_search_result_location": + return { type: "citation", citedText, source }; + case "page_location": { + // Anthropic page numbers are 1-indexed and inclusive on both + // ends per the documented PDF citation shape. + const start = wire.start_page_number; + const end = wire.end_page_number; + if (start === undefined || end === undefined) { + throw new ProtocolMismatchError( + `anthropic parseResponse: page_location citation at block ${index} missing start_page_number or end_page_number`, + wire, + ); + } + return { + type: "citation", + citedText, + source, + location: { kind: "page", start, end }, + }; + } + case "char_location": { + const start = wire.start_char_index; + const end = wire.end_char_index; + if (start === undefined || end === undefined) { + throw new ProtocolMismatchError( + `anthropic parseResponse: char_location citation at block ${index} missing start_char_index or end_char_index`, + wire, + ); + } + return { + type: "citation", + citedText, + source, + location: { kind: "char", start, end }, + }; + } + case "content_block_location": { + const start = wire.start_block_index; + const end = wire.end_block_index; + if (start === undefined || end === undefined) { + throw new ProtocolMismatchError( + `anthropic parseResponse: content_block_location citation at block ${index} missing start_block_index or end_block_index`, + wire, + ); + } + return { + type: "citation", + citedText, + source, + location: { kind: "content-block", start, end }, + }; + } + default: + throw new ProtocolMismatchError( + `anthropic parseResponse: unrecognized citation variant "${wire.type}" at block ${index}`, + wire, + ); + } +} + +function toAnthropicBlock(block: ContentBlock): Record { + switch (block.type) { + case "text": + return { type: "text", text: block.text }; + + case "thinking": + return { + type: "thinking", + thinking: block.thinking, + ...(block.signature !== undefined + ? { signature: block.signature } + : {}), + }; + + case "redacted_thinking": + // The opaque `data` blob must echo back verbatim on every + // follow-up turn that includes this block as context. Any + // mutation (truncation, base64-decoding-and-reencoding, + // whitespace normalization) produces a 400 from Anthropic with + // "messages.N.content.M.redacted_thinking: Field required" or + // a context-corruption error on subsequent turns. The + // RedactedThinkingBlock type carries it as `string` (opaque + // base64); pass through untouched. + return { type: "redacted_thinking", data: block.data }; + + case "image": + return { type: "image", source: toAnthropicMediaSource(block.source) }; + + case "document": + return { + type: "document", + source: toAnthropicMediaSource(block.source), + ...(block.title !== undefined ? { title: block.title } : {}), + ...(block.context !== undefined ? { context: block.context } : {}), + }; + + case "audio": + case "video": + throw new Error( + `Anthropic adapter does not yet handle ${block.type} content blocks.`, + ); + + case "citation": + throw new Error( + "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( + `Anthropic adapter does not yet emit ${block.type} content blocks.`, + ); + + case "refusal": + // Refusal blocks are an OpenAI strict-mode output shape. Echoing + // one back into an Anthropic request has no defined wire shape; + // surface the mismatch at the marshaling site rather than fall + // through to a silent drop. + throw new Error( + "Anthropic adapter does not handle refusal content blocks; " + + "they are emitted by OpenAI strict-mode structured outputs.", + ); + + case "tool_call": + return { + type: "tool_use", + id: block.id, + name: encodeToolName(block.name, ANTHROPIC_TOOL_NAME_LIMIT), + input: block.arguments, + }; + + case "tool_result": + return { + type: "tool_result", + tool_use_id: block.callId, + content: block.content.map((c) => { + if (c.type === "text") { + return { type: "text", text: c.text }; + } + if (c.type === "image") { + return { + type: "image", + source: toAnthropicMediaSource(c.source), + }; + } + // Anthropic's tool_result.content accepts only `text` and + // `image` blocks today. `document` in particular is rejected + // at the API edge; surface the failure at the marshaling + // site with the specific block type so the failure shows + // where the wrong block type was authored, not as an opaque + // HTTP 400 a round-trip later. The ContentBlock union allows + // these so the type system can grow uniformly; the wire + // surface lags. + throw new Error( + `Anthropic adapter does not handle ${c.type} content blocks ` + + `inside tool_result.content; the API accepts only text and ` + + `image here.`, + ); + }), + ...(block.isError ? { is_error: true } : {}), + }; + } +} + +// --------------------------------------------------------------------------- +// Response parsing +// +// The harness passes one SSE data payload per call. The parser accumulates +// no state — all partial state lives in the harness. The parser emits events +// for the fragments it sees; the harness updates the PartialMessage and +// injects it into the returned events. +// +// Because the harness owns partial state, the parser cannot construct the +// correct `partial` field. We emit raw delta events with a placeholder empty +// partial — the harness will replace it before forwarding. This is the +// design: adapters are pure translators, the harness owns all state. +// --------------------------------------------------------------------------- + +const EMPTY_PARTIAL: PartialMessage = { text: "" }; + +// Internal intermediate type used to communicate Anthropic-specific +// delta information to the harness before it enriches with partial state. +export type AnthropicRawEvent = + | { kind: "text_delta"; token: string } + | { kind: "thinking_delta"; token: string } + | { kind: "tool_call_start"; index: number; callId: string; name: string } + | { kind: "tool_call_delta"; index: number; argumentFragment: string } + | { kind: "tool_call_end"; index: number } + | { + kind: "usage"; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + thinkingTokens: number; + } + | { kind: "message_stop" } + | { kind: "skip" }; + +// Anthropic's SSE protocol guarantees `index` on every content_block_* +// event. Parsing it as required (not optional) means the type system +// carries the guarantee through to every emission site below — no +// defensive `?? 0` fallback that would silently route real protocol +// violations to block 0 and corrupt the `blockIndexToCallId` cache the +// parser uses to resolve input_json_delta lookups across multiple +// tool_use blocks at distinct indices. A malformed upstream missing +// `index` surfaces as a ProtocolMismatchError via the schema-validation +// throw site, with the offending payload preserved in `error.raw` for +// inspection. +// Anthropic's wire shape for a single citation, streamed inside a +// `citations_delta`. The `type` discriminator selects the location +// model: +// - web_search_result_location: URL + title, no document offsets +// - page_location: 1-indexed page numbers (inclusive start/end) +// - char_location: 0-indexed character offsets into the document +// - content_block_location: index into the document's content blocks +// Fields not relevant to a given variant are absent; the union is +// flat at the wire level. `encrypted_index` (web_search) is recorded +// only on the wire — it has no echo-back target in the internal +// CitationBlock today, so the adapter drops it. +const AnthropicCitation = type({ + type: "string", + "cited_text?": "string", + "url?": "string", + "title?": "string", + "encrypted_index?": "string", + "document_index?": "number", + "document_title?": "string", + "start_page_number?": "number", + "end_page_number?": "number", + "start_char_index?": "number", + "end_char_index?": "number", + "start_block_index?": "number", + "end_block_index?": "number", +}); + +const ContentBlockDelta = type({ + type: "'content_block_delta'", + index: "number", + delta: { + type: "string", + "text?": "string", + "thinking?": "string", + "partial_json?": "string", + "signature?": "string", + "citation?": AnthropicCitation, + }, +}); + +const ContentBlockStart = type({ + type: "'content_block_start'", + index: "number", + // Anthropic sends either content_block (snake_case) or contentBlock + // (camelCase). `data` is optional on the shared shape because only + // redacted_thinking blocks carry it; the redacted_thinking branch in + // the parser asserts presence and throws ProtocolMismatchError when + // it is missing, rather than synthesizing an empty string that would + // round-trip back to Anthropic as a corrupted block. + "content_block?": { + type: "string", + "id?": "string", + "name?": "string", + "data?": "string", + }, + "contentBlock?": { + type: "string", + "id?": "string", + "name?": "string", + "data?": "string", + }, +}); + +const ContentBlockStop = type({ + type: "'content_block_stop'", + index: "number", +}); + +const MessageDelta = type({ + type: "'message_delta'", + "usage?": { "output_tokens?": "number" }, +}); + +const MessageStart = type({ + type: "'message_start'", + "message?": { + "usage?": { + "input_tokens?": "number", + "output_tokens?": "number", + "cache_read_input_tokens?": "number", + "cache_creation_input_tokens?": "number", + }, + }, +}); + +const MessageStop = type({ type: "'message_stop'" }); +const Ping = type({ type: "'ping'" }); + +const AnthropicSSEEvent = ContentBlockDelta.or(ContentBlockStart) + .or(ContentBlockStop) + .or(MessageDelta) + .or(MessageStart) + .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, + source: LastCycleSource, +): InferenceEvent[] { + // Same protocol-mismatch posture as the openai adapter: a JSON parse + // failure or arktype rejection means the upstream emitted bytes that + // violate the Anthropic streaming protocol. Surface through + // ProtocolMismatchError so the harness's stream-error catch emits + // an inference.error with category "protocol_mismatch" carrying the + // offending data in error.raw, rather than dropping the chunk + // silently. + let parsed: unknown; + try { + parsed = JSON.parse(sseData); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new ProtocolMismatchError( + `anthropic parseResponse: malformed JSON in SSE data payload: ${message}`, + sseData, + ); + } + + const event = AnthropicSSEEvent(parsed); + if (event instanceof type.errors) { + throw new ProtocolMismatchError( + `anthropic parseResponse: SSE event failed schema validation: ${event.summary}`, + parsed, + ); + } + + // The seq field is a placeholder 0 — the harness assigns real sequence numbers. + const seq = 0; + + switch (event.type) { + case "content_block_delta": { + const { delta, index } = event; + + if (delta.type === "text_delta") { + const token = delta.text ?? ""; + return [ + { + type: "inference.text.delta", + seq, + data: { token, partial: EMPTY_PARTIAL, index }, + }, + ]; + } + + if (delta.type === "thinking_delta") { + const token = delta.thinking ?? ""; + return [ + { + type: "inference.thinking.delta", + seq, + data: { token, partial: EMPTY_PARTIAL, index }, + }, + ]; + } + + if (delta.type === "signature_delta") { + // Anthropic emits the cryptographic signature for a thinking block + // in a dedicated signature_delta event after the block's + // thinking_delta stream. The signature must be echoed back on any + // follow-up turn that includes the thinking block as context — + // otherwise the API rejects the request with + // "messages.N.content.M.thinking.signature: Field required". + const signature = delta.signature ?? ""; + return [ + { + type: "inference.block.signature", + seq, + data: { signature, index }, + }, + ]; + } + + if (delta.type === "input_json_delta") { + const callId = blockIndexToCallId.get(index); + if (callId === undefined) { + throw new ProtocolMismatchError( + `anthropic parseResponse: input_json_delta for content block ${index} with no preceding tool_use start`, + event, + ); + } + const fragment = delta.partial_json ?? ""; + return [ + { + type: "inference.tool_call.delta", + seq, + data: { + callId, + argumentFragment: fragment, + partial: EMPTY_PARTIAL, + index, + }, + }, + ]; + } + + if (delta.type === "citations_delta") { + const wireCitation = delta.citation; + if (wireCitation === undefined) { + throw new ProtocolMismatchError( + `anthropic parseResponse: citations_delta missing citation payload at block ${index}`, + event, + ); + } + const citation = toCitationBlock(wireCitation, index); + return [ + { + type: "inference.citation", + seq, + data: { citation, index }, + }, + ]; + } + + return []; + } + + case "content_block_start": { + const block = event.content_block ?? event.contentBlock; + if (block === undefined) return []; + + if (block.type === "tool_use") { + const { index } = event; + const callId = block.id ?? String(index); + blockIndexToCallId.set(index, callId); + const name = decodeToolName(block.name ?? ""); + return [ + { + type: "inference.tool_call.start", + seq, + data: { callId, name, partial: EMPTY_PARTIAL, index }, + }, + ]; + } + + if (block.type === "thinking") { + // Anchor the thinking block in the harness's per-index map + // via an empty thinking.delta. Anthropic can stream a + // signature_delta for a thinking block whose visible text is + // empty (redacted-adjacent flow); without this anchor, the + // signature would arrive at the harness with no preceding + // thinking entry at the same index and the per-index router + // would (correctly) reject it as a protocol violation. The + // empty-token delta is the parser-side analogue of the wire's + // `content_block_start` for thinking — it carries no visible + // content but reserves the index. + const { index } = event; + return [ + { + type: "inference.thinking.delta", + seq, + data: { token: "", partial: EMPTY_PARTIAL, index }, + }, + ]; + } + + if (block.type === "redacted_thinking") { + // Anthropic delivers redacted_thinking as a one-shot inside + // content_block_start (no delta stream). The opaque `data` + // blob must echo back verbatim on every follow-up turn — + // mutating or synthesizing it corrupts the conversation + // context. A start event missing `data` is a protocol + // violation, not a default-to-empty case. + const { index } = event; + if (block.data === undefined) { + throw new ProtocolMismatchError( + `anthropic parseResponse: content_block_start of type redacted_thinking ` + + `at index ${String(index)} missing required \`data\` field`, + event, + ); + } + return [ + { + type: "inference.thinking.redacted", + seq, + data: { + redactedThinking: { + type: "redacted_thinking", + data: block.data, + }, + index, + }, + }, + ]; + } + + // Non-tool_use, non-redacted_thinking content_block_start events + // (text, thinking) emit nothing here by design: each + // content_block_delta arrives with a typed delta (text_delta, + // thinking_delta, signature_delta) that the switch above + // discriminates on directly, so an upfront start emission would + // be redundant. Tool calls are the exception because their + // callId arrives only in the start event and must be cached + // against the block index for subsequent input_json_delta + // lookups; redacted_thinking is the exception because the block + // is delivered start-only with no follow-on deltas. + return []; + } + + case "content_block_stop": { + // The harness handles finalizing tool calls when it sees this — we + // emit nothing here; the harness knows which blocks are complete. + return []; + } + + case "message_delta": { + const outputTokens = event.usage?.output_tokens ?? 0; + const inferenceUsage: TokenUsage = { + input: 0, + output: outputTokens, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }; + return [ + { + type: "inference.usage", + seq, + data: { usage: inferenceUsage, source }, + }, + ]; + } + + case "message_start": { + blockIndexToCallId.clear(); + const msgUsage = event.message?.usage; + if (msgUsage === undefined) return []; + + return [ + { + type: "inference.usage", + seq, + data: { usage: toInferenceUsage(msgUsage), source }, + }, + ]; + } + + case "message_stop": + case "ping": + return []; + } +} + +// --------------------------------------------------------------------------- +// 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; + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return Math.ceil(seconds * 1000); +} + +function extractPacingDelayMs(headers: Headers): number | undefined { + // Check all rate limit dimensions and return the longest wait needed + const delays: number[] = []; + + for (const prefix of [ + "anthropic-ratelimit-requests", + "anthropic-ratelimit-input-tokens", + "anthropic-ratelimit-output-tokens", + "anthropic-ratelimit-tokens", + ]) { + const remaining = headers.get(`${prefix}-remaining`); + if (remaining === null) continue; + const n = Number(remaining); + if (!Number.isFinite(n) || n > 0) continue; + + const reset = headers.get(`${prefix}-reset`); + if (reset === null) continue; + const resetTime = Date.parse(reset); + if (Number.isNaN(resetTime)) continue; + const delayMs = resetTime - Date.now(); + if (delayMs > 0) delays.push(delayMs); + } + + 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 new file mode 100644 index 000000000..1bed1df16 --- /dev/null +++ b/vendor/intx/inference/src/providers/google-genai-files.ts @@ -0,0 +1,289 @@ +import { type } from "arktype"; + +// Google's Generative Language Files API endpoint for raw single-part +// uploads. The "raw" upload protocol (declared via +// `X-Goog-Upload-Protocol: raw`) accepts the bytes as the request +// body and returns the file resource (uri + metadata) on the +// response. The resumable and multipart protocols target larger +// files and a different endpoint vocabulary; the helper here covers +// only the raw shape because it maps cleanly onto a single fetch. +const FILES_API_UPLOAD_DEFAULT_URL = + "https://generativelanguage.googleapis.com/upload/v1beta/files"; + +// Parseable-integer pattern. Accepts an optional minus sign followed +// by digits and nothing else. Excludes whitespace, trailing +// non-digits, scientific notation, decimals. `Number.parseInt` +// alone is permissive on all four ("42abc" parses to 42, " 42" +// parses to 42); this guard makes the wire schema honest. +const PARSEABLE_INTEGER = /^-?\d+$/; + +const FilesApiUploadResponse = type({ + file: { + // `> 0` rejects empty strings -- a "" uri is not dereferenceable + // and would slip through a `"string"` validator. Same for the + // mime type. + uri: "string > 0", + mimeType: "string > 0", + // `sizeBytes` lands as a stringified number on the wire. The + // schema accepts string or number; the parser normalizes both + // to a runtime integer at extraction time and throws on any + // value that is not a parseable integer. + "sizeBytes?": "string | number", + "name?": "string", + "state?": "string", + "source?": "string", + "createTime?": "string", + "updateTime?": "string", + "expirationTime?": "string", + "sha256Hash?": "string", + }, +}); + +/** + * Callable shape the helper accepts for `fetch` injection. Mirrors + * `Dependencies.fetch` on the inference harness, so test doubles + * stay structurally compatible across both surfaces. Bun's global + * `typeof fetch` carries non-callable members (e.g. `preconnect`) + * that test doubles would otherwise have to satisfy gratuitously. + */ +export type UploadGoogleGenAIFileFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export interface UploadGoogleGenAIFileOpts { + apiKey: string; + mimeType: string; + // Display name surfaced on the file resource. The Files API + // accepts arbitrary strings here; callers typically pass the + // local file's basename. + displayName: string; + bytes: Uint8Array; + // Optional override for the upload endpoint. Defaults to + // Google's public Files API full URL (path included). A + // self-hosted or regional endpoint can be substituted by + // passing the full target URL -- the helper does not append + // any path to this value. + uploadURL?: string; + // Optional fetch implementation. Defaults to the global `fetch`. + fetch?: UploadGoogleGenAIFileFetch; + // Optional AbortSignal for caller cancellation. Forwarded to + // `fetch` verbatim. + signal?: AbortSignal; +} + +export interface UploadedGoogleGenAIFile { + // The dereferenceable `fileUri` the caller threads back into a + // `fileData.fileUri` part on a subsequent inference request, or + // into a `MediaSource` with `kind: "file-reference"`. + fileUri: string; + // The MIME type the API recorded for the file (echoed from the + // upload request). + mimeType: string; + // Size in bytes, normalized from the wire's string-encoded + // integer. Absent on the wire becomes absent here -- the + // helper does not synthesize a value. + sizeBytes?: number; + // Provider-side file id (`files/`). Useful for management + // operations against the Files API (delete, list). + name?: string; + // Lifecycle state (`ACTIVE`, `PROCESSING`, `FAILED`). + state?: string; +} + +// Header values must be free of control characters (CR/LF are +// the smuggling-relevant ones, but NUL and other CTL bytes are +// also illegal per RFC 9110 §5.5 visible-US-ASCII rule). The +// downstream `fetch` typically rejects CR/LF but not NUL, so +// catching the broader set here keeps the diagnostic specific +// to the offending input. This is the boundary that turns +// caller strings into HTTP request structure (per the style +// skill's Data Validation rule), so the check belongs here. +// +// `apiKey` flows through the same guard because the rule is +// "validate at the boundary" -- the boundary cannot know an +// input's provenance, and the caller may not have run their own +// sanity check. +// The guard's whole purpose is to reject CR/LF/NUL/other CTL +// bytes before they reach the downstream fetch, so the regex +// MUST match those code points. +// eslint-disable-next-line no-control-regex +const FORBIDDEN_HEADER_CHARS = /[\x00-\x1f\x7f]/; +function assertHeaderValueSafe(name: string, value: string): void { + if (FORBIDDEN_HEADER_CHARS.test(value)) { + throw new Error( + `google-genai files-API upload: header ${JSON.stringify(name)} ` + + `contains a control character (CR, LF, NUL, or other CTL byte), ` + + `which would let the value smuggle additional headers or be ` + + `rejected downstream with a less specific message.`, + ); + } +} + +/** + * Upload bytes to the Gemini Files API and return the file URI. + * + * The Files API is the dereferencable handle path for Gemini media: + * upload once, reference the returned `fileUri` on subsequent + * inference requests via a `fileData.fileUri` part (or a + * `MediaSource` of `kind: "file-reference"` whose `reference` is the + * URI). The trade-off relative to inline `base64` is bytes-on-the-wire: + * inline payloads ship with every request, file references ship + * once and are then quoted. + * + * The helper exclusively uses the "raw" upload protocol (one + * `POST` carries the full bytes). The resumable and multipart + * protocols are out of scope; large-file workflows that need them + * should compose against the Gemini SDK directly. + * + * @throws an Error when the upload returns non-2xx, when the + * response is not JSON, or when the response shape does not + * carry a non-empty `file.uri` + `file.mimeType`. The thrown + * error's message names the failure mode; HTTP errors include + * the status code and a snippet of the response body, and + * schema/JSON failures carry a snippet of the response body too. + */ +export async function uploadGoogleGenAIFile( + opts: UploadGoogleGenAIFileOpts, +): Promise { + const fetchImpl = opts.fetch ?? fetch; + const url = opts.uploadURL ?? FILES_API_UPLOAD_DEFAULT_URL; + + assertHeaderValueSafe("Content-Type", opts.mimeType); + assertHeaderValueSafe("X-Goog-Upload-File-Name", opts.displayName); + assertHeaderValueSafe("x-goog-api-key", opts.apiKey); + + // The `x-goog-api-key` header is intentionally lowercase to + // match the wire shape Google's Files API responds to (the + // captured `request-headers.json` records the same casing). + // HTTP header names are case-insensitive on the wire, so the + // mixed-case neighbors are functionally identical -- the choice + // is documentation, not behavior. + const headers: Record = { + "Content-Type": opts.mimeType, + "X-Goog-Upload-Protocol": "raw", + "X-Goog-Upload-File-Name": opts.displayName, + "x-goog-api-key": opts.apiKey, + }; + + const init: RequestInit = { + method: "POST", + headers, + body: new Uint8Array(opts.bytes), + }; + // `RequestInit.signal` is typed as `AbortSignal | null` under + // `exactOptionalPropertyTypes`; only attach the property when + // the caller actually supplied a signal so an absent `signal` + // does not become `undefined` on the init object (which the + // overload then rejects). + if (opts.signal !== undefined) { + init.signal = opts.signal; + } + const response = await fetchImpl(url, init); + + // Read the body as text first, then parse JSON ourselves. This + // keeps a snippet of the actual server response available for + // both the JSON-parse and schema-mismatch error paths; reading + // through `response.json()` would consume the stream before the + // error site can sample it. + let body: string; + try { + body = await response.text(); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new Error( + `google-genai files-API upload: failed to read response body ` + + `(status ${String(response.status)} ${response.statusText}): ${message}`, + { cause }, + ); + } + + if (!response.ok) { + throw new Error( + `google-genai files-API upload failed: ${String(response.status)} ` + + `${response.statusText}: ${body.slice(0, 500)}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new Error( + `google-genai files-API upload: response was not valid JSON ` + + `(${message}): ${body.slice(0, 500)}`, + { cause }, + ); + } + + const validated = FilesApiUploadResponse(parsed); + if (validated instanceof type.errors) { + throw new Error( + `google-genai files-API upload: response did not match the expected ` + + `shape (missing file.uri/mimeType or a malformed file resource): ` + + `${validated.summary}; body: ${body.slice(0, 500)}`, + ); + } + + const { file } = validated; + // Normalize `sizeBytes` from `string | number | undefined` to + // `number | undefined`. The wire ships an integer as a string + // ("4193"); the helper rejects strings that are not exactly a + // parseable integer and numbers that are not integers (a size + // in bytes by definition is not fractional). `Number.parseInt` + // alone is permissive ("42abc" parses to 42) so the regex guard + // is what makes the contract honest. The final integer must + // also be non-negative (bytes count up from zero) and within + // JavaScript's safe-integer range -- Files API docs call this + // field int64, and a string like "9007199254740993" silently + // rounds when coerced to a JS number, so a precision check at + // the boundary keeps the returned value faithful to the wire. + function assertSafeNonNegativeInteger(n: number, raw: string | number): void { + if (n < 0) { + throw new Error( + `google-genai files-API upload: file.sizeBytes ` + + `${JSON.stringify(raw)} is negative; a byte count cannot be ` + + `less than zero.`, + ); + } + if (n > Number.MAX_SAFE_INTEGER) { + throw new Error( + `google-genai files-API upload: file.sizeBytes ` + + `${JSON.stringify(raw)} exceeds Number.MAX_SAFE_INTEGER and ` + + `cannot be represented as a JS number without precision loss.`, + ); + } + } + + let sizeBytes: number | undefined; + if (typeof file.sizeBytes === "string") { + if (!PARSEABLE_INTEGER.test(file.sizeBytes)) { + throw new Error( + `google-genai files-API upload: file.sizeBytes ` + + `${JSON.stringify(file.sizeBytes)} is not a parseable integer.`, + ); + } + const parsedSize = Number.parseInt(file.sizeBytes, 10); + assertSafeNonNegativeInteger(parsedSize, file.sizeBytes); + sizeBytes = parsedSize; + } else if (typeof file.sizeBytes === "number") { + if (!Number.isInteger(file.sizeBytes)) { + throw new Error( + `google-genai files-API upload: file.sizeBytes ` + + `${JSON.stringify(file.sizeBytes)} is not an integer.`, + ); + } + assertSafeNonNegativeInteger(file.sizeBytes, file.sizeBytes); + sizeBytes = file.sizeBytes; + } + + const result: UploadedGoogleGenAIFile = { + fileUri: file.uri, + mimeType: file.mimeType, + }; + if (sizeBytes !== undefined) result.sizeBytes = sizeBytes; + if (file.name !== undefined) result.name = file.name; + if (file.state !== undefined) result.state = file.state; + return result; +} diff --git a/vendor/intx/inference/src/providers/google-genai.ts b/vendor/intx/inference/src/providers/google-genai.ts new file mode 100644 index 000000000..3e4069a16 --- /dev/null +++ b/vendor/intx/inference/src/providers/google-genai.ts @@ -0,0 +1,1546 @@ +import { type } from "arktype"; + +import type { + CodeExecutionRequestBlock, + CodeExecutionResultBlock, + ConversationTurn, + ContentBlock, + InferenceEvent, + InferenceOptions, + LastCycleSource, + MediaSource, + 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"; +import { + decodeToolName, + encodeToolName, + type ToolNameLimit, +} from "../tool-name"; + +// Gemini rejects function names with out-of-charset characters and requires a +// letter/underscore leading character; the raw package-qualified names fail +// both. The documented function-name limit is 64 characters. +const GOOGLE_TOOL_NAME_LIMIT: ToolNameLimit = { + provider: "google-genai", + 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 +// type assertion -- the assertion would be a compile-time lie about +// runtime shape (per the project style guide), and arktype gives an +// honest runtime check. +const ParsedJSONObject = type("Record"); + +// --------------------------------------------------------------------------- +// Request building +// +// Translates the internal ConversationTurn[] format into Gemini's +// `generateContent` / `streamGenerateContent` request body. The harness +// always streams, so the URL pins `:streamGenerateContent?alt=sse`. +// --------------------------------------------------------------------------- + +function buildRequest( + messages: ConversationTurn[], + model: string, + options: InferenceOptions, +): BuiltRequest { + const systemMessages = messages.filter((m) => m.role === "system"); + const conversationMessages = messages.filter((m) => m.role !== "system"); + + // System text: concatenated from any system turns in history, unless + // the caller overrides via `options.systemPrompt`. Matches the + // precedence used by the Anthropic adapter. Non-text blocks in a + // system turn surface as an error rather than a silent drop -- the + // rest of the file fails loudly on unsupported block kinds and this + // boundary holds the same discipline. + const systemText = systemMessages + .flatMap((m) => + m.content.map((b) => { + if (b.type !== "text") { + throw new Error( + `Google GenAI adapter: system turn must contain only text blocks; got ${JSON.stringify(b.type)}.`, + ); + } + return b.text; + }), + ) + .join("\n\n"); + const effectiveSystem = options.systemPrompt + ? options.systemPrompt + : systemText || undefined; + + // A `callId -> functionName` lookup, built once per request from + // every prior assistant `tool_call` block. Gemini's + // `functionResponse` part requires the function name (Anthropic + // requires the callId); the internal `ToolResultBlock` carries only + // the callId, so the name comes from the assistant turn that + // produced the matching `tool_call`. Built once because a per-block + // walk would be O(N^2) in turn count. + const callIdToFunctionName = buildCallIdToFunctionName(messages); + + // 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 }; + + if (effectiveSystem !== undefined) { + body["systemInstruction"] = { parts: [{ text: effectiveSystem }] }; + } + + if (options.tools !== undefined && options.tools.length > 0) { + body["tools"] = [ + { + functionDeclarations: options.tools.map((t) => ({ + name: encodeToolName(t.name, GOOGLE_TOOL_NAME_LIMIT), + description: t.description, + parameters: t.inputSchema, + })), + }, + ]; + } + + const generationConfig = buildGenerationConfig(model, options); + if (generationConfig !== undefined) { + body["generationConfig"] = generationConfig; + } + + // Caller escape hatch. Documented as shallow-merge over the body + // top-level: a caller passing `providerOptions.generationConfig` + // wholesale replaces the object built above. Same shape semantics as + // the `InferenceOptions.providerOptions` contract on every other + // adapter -- the caller owns the consequences of clobbering a + // structured key. + if (options.providerOptions !== undefined) { + Object.assign(body, options.providerOptions); + } + + // Escape the model name in the URL path. `encodeURIComponent` is a + // no-op on the legitimate Gemini model names in use today + // (alphanumerics, hyphens, periods are all reserved-safe), but + // guards against future model values that arrive from outside + // trusted configuration. The trailing `:streamGenerateContent?alt=sse` + // sits outside the substitution so its colon and query string + // survive intact. + const encodedModel = encodeURIComponent(model); + + return { + url: `/v1beta/models/${encodedModel}:streamGenerateContent?alt=sse`, + headers: { + "content-type": "application/json", + "x-goog-api-key": CREDENTIAL_SENTINEL, + }, + body: JSON.stringify(body), + }; +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +// 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 rides +// each block's `signature` back as a `thoughtSignature` on that +// block's own part. +interface GeminiTextPart { + text: string; + thought?: boolean; + thoughtSignature?: string; +} +interface GeminiInlineDataPart { + inlineData: { mimeType: string; data: string }; + thoughtSignature?: string; +} +interface GeminiFileDataPart { + fileData: { mimeType: string; fileUri: string }; + thoughtSignature?: string; +} +interface GeminiFunctionCallPart { + functionCall: { name: string; args: Record }; + thoughtSignature?: string; +} +interface GeminiFunctionResponsePart { + functionResponse: { name: string; response: Record }; +} +type GeminiPart = + | GeminiTextPart + | GeminiInlineDataPart + | GeminiFileDataPart + | GeminiFunctionCallPart + | GeminiFunctionResponsePart; + +interface GeminiContent { + role: "user" | "model"; + parts: GeminiPart[]; +} + +// --------------------------------------------------------------------------- +// Conversation-turn translation +// --------------------------------------------------------------------------- + +function buildCallIdToFunctionName( + messages: ConversationTurn[], +): Map { + const map = new Map(); + for (const msg of messages) { + if (msg.role !== "assistant") continue; + for (const block of msg.content) { + if (block.type === "tool_call") { + map.set(block.id, block.name); + } + } + } + return map; +} + +function toGeminiContent( + msg: ConversationTurn, + callIdToFunctionName: Map, +): GeminiContent { + const role: "user" | "model" = msg.role === "assistant" ? "model" : "user"; + // Role/block pairing: Gemini wants `functionCall` parts only on + // `model`-role contents and `functionResponse` parts only on + // `user`-role contents. The internal `ContentBlock` union does not + // enforce the pairing on its own, so misrouted blocks (a `tool_call` + // on a user turn, a `tool_result` on an assistant turn) would + // otherwise reach Gemini and return an opaque 400. Catch them at + // the marshaling boundary with diagnostic context instead. + for (const block of msg.content) { + if (role === "user" && block.type === "tool_call") { + throw new Error( + `Google GenAI adapter: tool_call blocks must appear on assistant turns, ` + + `found one on a ${JSON.stringify(msg.role)} turn (id ${JSON.stringify(block.id)}).`, + ); + } + if (role === "model" && block.type === "tool_result") { + throw new Error( + `Google GenAI adapter: tool_result blocks must appear on user turns, ` + + `found one on a ${JSON.stringify(msg.role)} turn (callId ${JSON.stringify(block.callId)}).`, + ); + } + } + + // 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 }; +} + +function toGeminiPart( + block: ContentBlock, + callIdToFunctionName: Map, +): GeminiPart { + switch (block.type) { + case "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 "document": + case "audio": + case "video": + return toGeminiMediaPart(block.source); + + case "tool_call": + return { + functionCall: { + 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": + // 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 + // passing one in is mixing wire formats. Surface the mismatch + // loudly rather than dropping it silently. + throw new Error( + "Google GenAI adapter does not handle redacted_thinking blocks; " + + "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 + // back in an input turn has no defined wire shape and is almost + // certainly a caller bug -- fail rather than send a nonsense + // request. + throw new Error( + "Google GenAI adapter does not echo citation blocks; citations " + + "are emitted by the model, not sent to it.", + ); + + case "code_execution_request": + case "code_execution_result": + // Code-execution round-trip needs Gemini's + // `executableCode`/`codeExecutionResult` part shapes, which + // the adapter does not emit. Surface the gap rather than + // produce a request with these blocks missing. + throw new Error( + `Google GenAI adapter does not handle ${block.type} content blocks.`, + ); + + case "refusal": + // Refusal blocks are an OpenAI strict-mode output shape and have + // no Gemini wire equivalent. Echoing one back into a Gemini + // request has no defined translation; fail loudly at the + // marshaling site rather than silently drop the block. + throw new Error( + "Google GenAI adapter does not handle refusal content blocks; " + + "they are emitted by OpenAI strict-mode structured outputs.", + ); + } +} + +// Marshal an internal MediaSource into a Gemini part. `base64` +// inlines the bytes; `file-reference` and `url` both target Gemini's +// `fileData` with `fileUri` -- the Files API returns URIs, and Gemini +// also accepts public HTTP(S) URLs through the same field. +function toGeminiMediaPart( + source: MediaSource, +): GeminiInlineDataPart | GeminiFileDataPart { + if (source.kind === "base64") { + return { + inlineData: { mimeType: source.mimeType, data: source.data }, + }; + } + if (source.kind === "file-reference") { + return { + fileData: { mimeType: source.mimeType, fileUri: source.reference }, + }; + } + if (source.kind === "url") { + return { + fileData: { mimeType: source.mimeType, fileUri: source.url }, + }; + } + // Exhaustiveness: a new MediaSource variant added without a case + // here fails this compile-time check. + source satisfies never; + throw new Error(`unreachable: unknown MediaSource kind`); +} + +// Marshal a tool_result into Gemini's functionResponse part shape. +// The contract is deliberately strict: Gemini's `response` is a JSON +// object, and a permissive "guess at the shape" mapping silently +// reshapes payloads when callers don't intend it. The four accepted +// shapes are: +// +// - exactly one text block whose text parses as a plain JSON object +// -> that object becomes `response` +// - exactly one text block whose text does not parse as an object +// -> `{ result: text }` (or `{ error: text }` when isError is true) +// - zero or multiple text blocks -> throw; the caller must collapse +// to a single text block before handing the tool_result to the +// adapter +// - any non-text block (image/audio/video/document) inside the +// tool_result -> throw; Gemini's functionResponse accepts no media +// +// The unknown-callId case throws with the unknown id and the set of +// known ids so a malformed conversation surfaces at the marshaling +// site instead of as an opaque HTTP 400 a round-trip later. +function toGeminiFunctionResponse( + block: Extract, + callIdToFunctionName: Map, +): GeminiFunctionResponsePart { + const name = callIdToFunctionName.get(block.callId); + if (name === undefined) { + const known = Array.from(callIdToFunctionName.keys()); + throw new Error( + `Google GenAI adapter: tool_result.callId ${JSON.stringify(block.callId)} ` + + `has no matching tool_call in the conversation history. ` + + `Known callIds: ${known.length === 0 ? "(none)" : known.map((k) => JSON.stringify(k)).join(", ")}.`, + ); + } + + if (block.content.length !== 1) { + throw new Error( + `Google GenAI adapter: tool_result must contain exactly one text block, ` + + `got ${String(block.content.length)} blocks for callId ` + + `${JSON.stringify(block.callId)}.`, + ); + } + const only = block.content[0]; + if (only === undefined || only.type !== "text") { + const seenType = only?.type ?? "undefined"; + throw new Error( + `Google GenAI adapter: tool_result content block must be of type "text", ` + + `got ${JSON.stringify(seenType)} for callId ${JSON.stringify(block.callId)}.`, + ); + } + + const text = only.text; + const parsed = tryParseJSONObject(text); + + let response: Record; + if (parsed !== null) { + response = parsed; + } else if (block.isError === true) { + response = { error: text }; + } else { + response = { result: text }; + } + + return { + functionResponse: { + name: encodeToolName(name, GOOGLE_TOOL_NAME_LIMIT), + response, + }, + }; +} + +// Returns the parsed value when `text` is a JSON-encoded plain +// object, or `null` for any other shape: arrays, primitives +// (numbers, strings, booleans, null), and JSON parse errors all map +// to `null`. Wrapping is the responsibility of the caller -- this +// helper only confirms "is the text exactly a JSON object we can use +// verbatim." +function tryParseJSONObject(text: string): Record | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + // `ParsedJSONObject` (arktype `Record`) accepts + // arrays -- in arktype's view an array IS a record with + // numeric-string keys -- so the array-rejection has to happen + // before the validator runs. Without this guard, a tool that + // returns `"[1,2,3]"` would be silently promoted to a `response` + // shape Gemini cannot consume. + if (Array.isArray(parsed)) { + return null; + } + const validated = ParsedJSONObject(parsed); + if (validated instanceof type.errors) { + return null; + } + return validated; +} + +// --------------------------------------------------------------------------- +// generationConfig +// --------------------------------------------------------------------------- + +function buildGenerationConfig( + model: string, + options: InferenceOptions, +): Record | undefined { + const config: Record = {}; + + if (options.maxTokens !== undefined) { + config["maxOutputTokens"] = options.maxTokens; + } + if (options.temperature !== undefined) { + config["temperature"] = options.temperature; + } + + // thinking.enabled === true -> include a budget (default 1024) and + // ask Gemini to surface thought parts + // 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) { + if (options.thinking.enabled) { + const thinkingBudget = options.thinking.budgetTokens ?? 1024; + config["thinkingConfig"] = { + thinkingBudget, + includeThoughts: true, + }; + } else { + config["thinkingConfig"] = { + thinkingBudget: minimalThinkingBudget(model), + }; + } + } + + if ( + options.responseModalities !== undefined && + options.responseModalities.length > 0 + ) { + config["responseModalities"] = + options.responseModalities.map(toGeminiModality); + } + + if (options.responseFormat !== undefined) { + applyResponseFormat(config, options.responseFormat); + } + + return Object.keys(config).length === 0 ? undefined : config; +} + +// Translate the internal `responseFormat` union to Gemini's +// generationConfig fields. Gemini exposes structured outputs through +// the pair (`responseMimeType`, `responseSchema`) rather than a +// dedicated union: setting the MIME type alone gives free-form JSON; +// pairing it with a schema constrains the output to schema-conformant +// JSON. The OpenAI-specific `name` and `strict` fields have no Gemini +// equivalent and are ignored when present. +// +// The `schema` field is forwarded verbatim. Gemini enforces a JSON +// Schema subset (no `oneOf`, limited `pattern`, no `$ref`, etc.); the +// adapter does not pre-validate the caller's schema against that +// subset and instead surfaces Gemini's HTTP error if the model +// rejects it. INFERENCE.md documents the subset for callers. +function applyResponseFormat( + config: Record, + format: NonNullable, +): void { + switch (format.kind) { + case "text": + // Free-form text is Gemini's default; omitting the MIME type + // produces the same behavior. Set nothing to keep the request + // body minimal. + return; + case "json": + config["responseMimeType"] = "application/json"; + return; + case "json-schema": + config["responseMimeType"] = "application/json"; + config["responseSchema"] = format.schema; + return; + } +} + +function toGeminiModality(m: "text" | "image" | "audio"): string { + switch (m) { + case "text": + return "TEXT"; + case "image": + return "IMAGE"; + case "audio": + return "AUDIO"; + } +} + +// --------------------------------------------------------------------------- +// Response parsing +// +// Each Gemini SSE event is one complete JSON object delivered through +// `parseSSE` (event boundary `\n\n`); a partial JSON would mean the +// SSE framing layer broke its contract, not a Gemini protocol +// violation. Per the adapter contract in +// `packages/inference/src/adapter.ts`, `ProtocolMismatchError` is the +// only throw type the parser is allowed to raise -- the harness's +// `classifyStreamError` recognizes it. +// +// Text deltas on the Gemini wire are incremental: each event carries +// only the new tokens, not the accumulated text. The harness owns +// partial-state accumulation; the parser emits placeholder +// `EMPTY_PARTIAL` and the harness fills the real value in. +// --------------------------------------------------------------------------- + +const EMPTY_PARTIAL: PartialMessage = { text: "" }; + +// Wire shape: every field is optional. Gemini emits candidates without +// content during safety-filter rejections, sends events with only +// `usageMetadata` populated, and may omit `finishReason` on every +// event except the terminal one. The parser handles the absences +// directly rather than via schema-default coercion. +// +// The schema models the five payload kinds the parser handles: +// `text`, `functionCall`, `inlineData` (image output), +// `executableCode` (code-execution request), and +// `codeExecutionResult` (code-execution result). They are mutually +// exclusive on the wire: a single part is one kind of content. +// Arktype's open-object semantics will accept multiple set +// simultaneously, so `parseResponse` enforces the exclusivity at +// the boundary via `assertSinglePayload` and throws +// `ProtocolMismatchError` on a violation. `inlineData` is +// additionally constrained to `image/*` MIME types at the +// `emitPart` boundary; a non-image MIME on `inlineData` is treated +// as a wire shape the parser does not handle (rather than silently +// wrapping arbitrary bytes as an ImageBlock). +// +// `thought` and `thoughtSignature` are metadata that ride alongside +// the payload: `thought: true` is only meaningful on a `text` part +// (a non-text part with `thought: true` is a wire violation rejected +// at the boundary), and `thoughtSignature` carries the opaque +// per-thinking-block signature that Gemini requires echoed back on +// follow-up turns. Both can be absent. +const GeminiFunctionCallPayload = type({ + name: "string", + args: "Record", +}); + +const GeminiInlineDataPayload = type({ + mimeType: "string", + data: "string", +}); + +const GeminiExecutableCodePayload = type({ + language: "string", + code: "string", +}); + +const GeminiCodeExecutionResultPayload = type({ + outcome: "string", + // The combined stdout/stderr stream. Gemini does not split the + // streams; the parser routes this verbatim into the result + // block's `stdout` and leaves `stderr` empty (per the contract + // documented on `CodeExecutionResultBlock`). + "output?": "string", +}); + +const GeminiPart = type({ + "text?": "string", + "thought?": "boolean", + "thoughtSignature?": "string", + "functionCall?": GeminiFunctionCallPayload, + "inlineData?": GeminiInlineDataPayload, + "executableCode?": GeminiExecutableCodePayload, + "codeExecutionResult?": GeminiCodeExecutionResultPayload, +}); + +const GeminiContent = type({ + "parts?": GeminiPart.array(), + "role?": "string", +}); + +// Grounding metadata rides on a candidate whenever the request +// enabled `tools: [{googleSearch: {}}]`. The captured fixture +// shows `groundingMetadata: {}` present on every SSE event with +// `groundingChunks`/`groundingSupports` populated only on the +// terminal event; intermediate empty-metadata events short-circuit +// in `emitGroundingCitations` via the `supports.length === 0` +// early return. The two arrays the parser consumes are: +// +// - `groundingChunks[].web`: per-source `{uri, title}` entries. +// Indexed positionally; the chunks are the citation sources. +// +// - `groundingSupports[]`: pairings between an output text span +// (`segment: {startIndex, endIndex, text}`) and one or more +// chunk indices (`groundingChunkIndices: number[]`). Each +// index-into-chunks expands into one CitationBlock during +// emission. +// +// `searchEntryPoint` (HTML rendering widget) and `webSearchQueries` +// (the model-issued queries) carry no per-text-span attribution and +// are not surfaced as citation blocks. Validating them here would +// pin a wire shape the parser does not consume; the schema admits +// them implicitly via arktype's open-object semantics. +const GeminiGroundingChunk = type({ + // Each chunk currently arrives with a single `web` shape. Other + // chunk kinds (e.g. document, retrieved-context) are not in the + // captured corpus; admitting them as schema-validated absences + // keeps `web`-shaped chunks well-typed without committing to a + // discriminated union the parser cannot dispatch over. + "web?": type({ uri: "string", title: "string" }), +}); + +const GeminiGroundingSupport = type({ + segment: { + startIndex: "number", + endIndex: "number", + text: "string", + }, + groundingChunkIndices: "number[]", +}); + +const GeminiGroundingMetadata = type({ + "groundingChunks?": GeminiGroundingChunk.array(), + "groundingSupports?": GeminiGroundingSupport.array(), +}); + +const GeminiCandidate = type({ + "content?": GeminiContent, + "finishReason?": "string", + "index?": "number", + "groundingMetadata?": GeminiGroundingMetadata, +}); + +// `thoughtsTokenCount` is populated on responses with thinking +// enabled; it maps directly onto `TokenUsage.thinking`. +// `cachedContentTokenCount` is populated when context caching is in +// use and maps onto `TokenUsage.cacheRead`. Both are absent on +// responses that don't exercise the corresponding feature, and the +// parser treats absence as zero. +const GeminiUsageMetadata = type({ + "promptTokenCount?": "number", + "candidatesTokenCount?": "number", + "totalTokenCount?": "number", + "thoughtsTokenCount?": "number", + "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 + // (`gemini-2.5-flash` requested may return `gemini-2.5-flash-001`). + // Surfacing the served version is a separate concern; for now the + // request-side identifier is what downstream consumers see. + "modelVersion?": "string", + "responseId?": "string", +}); + +// Per-request parser state. Gemini provides no explicit content-block +// index on the wire -- block boundaries are positional, derived from +// the order and kind of parts. The parser allocates indices itself +// and coalesces consecutive same-kind parts into one logical block. +// +// - `nextBlockIndex` is the monotonic counter for newly allocated +// blocks across the entire request (incremented on each +// allocation, never reset). +// +// - `currentBlock` is the in-progress block that subsequent +// same-kind parts extend. Reset to `null` when a different-kind +// part appears -- the next part of any kind starts a fresh block. +// Function-call blocks are atomic (a single part = a complete +// tool call) and never become the `currentBlock`. +// +// 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; + // 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 + // immediately-following `codeExecutionResult` part. The wire + // convention (from the captured Gemini fixture) is strict LIFO + // with depth 1: request, then result, then optional follow-on + // text. The depth-1 invariant is enforced: a second request + // arriving while the slot is occupied, a result arriving with + // the slot empty, and a non-empty slot at the end of a response + // all throw `ProtocolMismatchError`. + pendingExecutionRequestId: string | null; +} + +function createParserState(): GeminiParserState { + return { + nextBlockIndex: 0, + currentBlock: 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. +function openOrExtendBlock( + state: GeminiParserState, + kind: "text" | "thinking", +): number { + if (state.currentBlock !== null && state.currentBlock.kind === kind) { + return state.currentBlock.index; + } + closeCurrentBlock(state); + const index = state.nextBlockIndex++; + state.currentBlock = { kind, index }; + return 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; +} + +// Enforce mutual exclusivity of payload-bearing fields and correct +// placement of the `thought` flag on a single part. The schema +// models five payload fields (`text`, `functionCall`, `inlineData`, +// `executableCode`, `codeExecutionResult`); arktype's open-object +// 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 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, +): void { + const payloads: string[] = []; + if (part.text !== undefined) payloads.push("text"); + if (part.functionCall !== undefined) payloads.push("functionCall"); + if (part.inlineData !== undefined) payloads.push("inlineData"); + if (part.executableCode !== undefined) payloads.push("executableCode"); + if (part.codeExecutionResult !== undefined) { + payloads.push("codeExecutionResult"); + } + + if (payloads.length > 1) { + throw new ProtocolMismatchError( + `google-genai parseResponse: part has multiple payload fields set ` + + `(${payloads.join("+")}); exactly one of ` + + `{text, functionCall, inlineData, executableCode, ` + + `codeExecutionResult} must be present per Gemini wire convention.`, + raw, + ); + } + if (payloads.length === 0 && part.thoughtSignature === undefined) { + throw new ProtocolMismatchError( + `google-genai parseResponse: part has no payload and no ` + + `thoughtSignature; an empty part is not a defined wire shape.`, + raw, + ); + } + // `thought: true` is only meaningful on a text part; the flag's + // sole purpose is to discriminate thinking text from regular + // assistant text. A `thought` flag on a `functionCall` part or a + // payload-free part has no defined wire interpretation. + if (part.thought === true && part.text === undefined) { + throw new ProtocolMismatchError( + `google-genai parseResponse: \`thought: true\` set on a part with ` + + `no \`text\` payload; the flag is only valid on text parts.`, + raw, + ); + } +} + +function emitPart( + part: typeof GeminiPart.infer, + state: GeminiParserState, + seq: number, + out: InferenceEvent[], + raw: unknown, +): void { + assertSinglePayload(part, raw); + + // text part with `thought: true` -- belongs to a thinking block. + if (part.text !== undefined && part.thought === true) { + 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 + // empty-token delta mirrors the Anthropic adapter's anchoring + // pattern for the same invariant. + out.push({ + type: "inference.thinking.delta", + seq, + data: { + token: part.text, + partial: EMPTY_PARTIAL, + index, + }, + }); + // 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. 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 === "" && part.thoughtSignature === undefined) { + return; + } + const index = openOrExtendBlock(state, "text"); + out.push({ + type: "inference.text.delta", + seq, + data: { + token: part.text, + partial: EMPTY_PARTIAL, + index, + }, + }); + emitBlockSignature(part.thoughtSignature, index, seq, out); + return; + } + + // functionCall part -- atomic block, allocates a fresh index and + // 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); + const fc = part.functionCall; + const index = state.nextBlockIndex++; + // Synthetic callId: Gemini's `functionCall` has no wire-level id + // field. The harness keys on this id end-to-end (start, delta, + // round-trip lookup); `String(index)` matches the Anthropic + // adapter's fallback when its wire id is absent. Block indices + // are unique within a request by construction. + const callId = String(index); + + out.push({ + type: "inference.tool_call.start", + seq, + data: { + callId, + name: decodeToolName(fc.name), + partial: EMPTY_PARTIAL, + index, + }, + }); + // Gemini delivers `args` complete in a single part -- no + // streaming JSON fragments. Emit the full serialized args in one + // delta so the harness's end-of-stream finalization (which keys + // on `openToolCalls` and re-parses the accumulated argsBuffer) + // produces a `tool_call.end` with the correct arguments. The + // harness owns the `tool_call.end` emission; adapters emit only + // `start` + `delta`. + out.push({ + type: "inference.tool_call.delta", + seq, + data: { + callId, + argumentFragment: JSON.stringify(fc.args), + partial: EMPTY_PARTIAL, + 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. 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 + // mistype the payload. Reject at the boundary rather than + // produce a confidently-wrong ContentBlock. + if (!part.inlineData.mimeType.startsWith("image/")) { + throw new ProtocolMismatchError( + `google-genai parseResponse: inlineData part has non-image ` + + `mimeType ${JSON.stringify(part.inlineData.mimeType)}; the ` + + `parser wraps inlineData as an ImageBlock and does not ` + + `handle other modalities on this code path.`, + raw, + ); + } + closeCurrentBlock(state); + const index = state.nextBlockIndex++; + out.push({ + type: "inference.image_output", + seq, + data: { + image: { + type: "image", + source: { + kind: "base64", + mimeType: part.inlineData.mimeType, + data: part.inlineData.data, + }, + }, + index, + }, + }); + emitBlockSignature(part.thoughtSignature, index, seq, out); + return; + } + + // executableCode part -- atomic code-execution request block. + // Gemini delivers the full source in one part (no chunked code + // streaming), so a fresh block index is allocated and the request + // block is emitted in one `inference.code_execution.start` event. + // The synthetic id is `gemini-exec-` where `index` is the + // content-block index allocated within THIS response (deterministic + // per-response so replays of the same response produce the same + // ids). It satisfies the `CodeExecutionRequestBlock.id` contract + // ("synthesized by the adapter for providers that don't emit one, + // using a deterministic per-response position-based scheme so + // replays match"). The id then lands in + // `pendingExecutionRequestId` so the next codeExecutionResult + // part can back-point its `requestId` to it. + if (part.executableCode !== undefined) { + // Precondition first, before any state mutation or event + // emission: a depth-1 violation must not leave a half-applied + // close/allocate/settle sequence in `state` and `out`. The + // caller discards `out` on throw today, so the difference is + // not observable, but the ordering keeps the throw faithful + // to "this part was rejected entirely." + if (state.pendingExecutionRequestId !== null) { + throw new ProtocolMismatchError( + `google-genai parseResponse: encountered a second executableCode ` + + `part while the prior code-execution request ` + + `${JSON.stringify(state.pendingExecutionRequestId)} is still ` + + `unmatched. The wire convention is strict LIFO with depth 1 ` + + `(request, then result); no fixture exercises depth > 1.`, + raw, + ); + } + closeCurrentBlock(state); + const index = state.nextBlockIndex++; + + const requestId = `gemini-exec-${String(index)}`; + state.pendingExecutionRequestId = requestId; + + const ec = part.executableCode; + const request: CodeExecutionRequestBlock = { + type: "code_execution_request", + id: requestId, + code: ec.code, + // Pass `language` through verbatim. Gemini emits SCREAMING_CASE + // (e.g. `"PYTHON"`); the type contract is "adapters MUST NOT + // default this -- callers narrow on its presence." Comparing + // values cross-provider requires case-insensitive logic at + // the consumer. + language: ec.language, + }; + out.push({ + type: "inference.code_execution.start", + 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; + } + + // codeExecutionResult part -- atomic result block. Pairs against + // the most recently emitted `executableCode` part via + // `pendingExecutionRequestId` (Gemini's wire carries no explicit + // back-pointer; the immediately-preceding request is the + // implicit owner). The slot read is destructive: clearing it + // here forces the depth-1 invariant on subsequent parts, and a + // result arriving with the slot empty throws. + if (part.codeExecutionResult !== undefined) { + // Precondition first, before any state mutation or event + // emission: an empty-slot violation must not leave a + // half-applied close/allocate/settle sequence behind. Same + // discipline as the executableCode branch above. + const requestId = state.pendingExecutionRequestId; + if (requestId === null) { + throw new ProtocolMismatchError( + `google-genai parseResponse: codeExecutionResult part has no ` + + `preceding executableCode part in this request to pair against.`, + raw, + ); + } + // outcomeToStatus throws on an unknown outcome -- run it before + // any other state mutation so the throw cleanly rejects the + // 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); + const index = state.nextBlockIndex++; + state.pendingExecutionRequestId = null; + + const result: CodeExecutionResultBlock = { + type: "code_execution_result", + requestId, + status, + // Gemini's `output` is the combined stdout+stderr stream. + // Per the `CodeExecutionResultBlock.stdout` comment, providers + // that don't split the streams map their combined output here + // and leave `stderr` empty. + ...(cer.output !== undefined ? { stdout: cer.output } : {}), + providerOutcome: cer.outcome, + }; + out.push({ + type: "inference.code_execution.result", + seq, + data: { result, index }, + }); + return; + } + + // 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) { + 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 + // case, so a part that lands here had a payload that no earlier + // branch claimed. The schema models five payload fields (`text`, + // `functionCall`, `inlineData`, `executableCode`, + // `codeExecutionResult`); all five have their own branches + // above. Reaching this line implies the schema has grown a new + // payload field without a matching branch in `emitPart`. + throw new ProtocolMismatchError( + `google-genai parseResponse: unhandled part shape; the schema admits ` + + `a payload field that emitPart has no branch for.`, + raw, + ); +} + +// Emit `inference.citation` events from a candidate's +// `groundingMetadata`. Each `groundingSupport` expands into one +// citation per referenced chunk: a span that cites four sources +// produces four citations with the same `citedText` and +// `textOffset` but distinct `source` entries. Consumers see the +// full attribution list and can de-duplicate by URI if they want +// to collapse identical sources. +// +// The text-block anchor is read from `state.currentBlock` -- the +// just-processed text parts in this same event will have left it +// set to the running text block. If currentBlock is not text (or +// is null), Gemini delivered grounding without a preceding text +// anchor, which has no defined attribution per the +// `CitationBlock` contract; surface as a protocol mismatch +// rather than synthesize an arbitrary index. +// +// `groundingChunks` entries without the `web` shape (a future +// chunk kind) are skipped silently for now -- their source has no +// `uri`/`title` to populate `CitationSource`, and synthesizing a +// placeholder citation would misrepresent the wire. Supports that +// reference an out-of-range chunk index throw -- the wire is +// pointing at a chunk slot the response never delivered, which is +// a wire bug we want to see. +function emitGroundingCitations( + metadata: typeof GeminiGroundingMetadata.infer, + state: GeminiParserState, + seq: number, + out: InferenceEvent[], + raw: unknown, +): void { + const supports = metadata.groundingSupports ?? []; + const chunks = metadata.groundingChunks ?? []; + if (supports.length === 0) { + return; + } + + const anchor = state.currentBlock; + if (anchor === null || anchor.kind !== "text") { + throw new ProtocolMismatchError( + `google-genai parseResponse: groundingMetadata arrived without a ` + + `current text block to anchor citations against (currentBlock=` + + `${anchor === null ? "null" : JSON.stringify(anchor.kind)}). The ` + + `wire convention places groundingMetadata on the terminal event ` + + `alongside the text it grounds.`, + raw, + ); + } + const index = anchor.index; + + for (const support of supports) { + const { segment, groundingChunkIndices } = support; + for (const chunkIdx of groundingChunkIndices) { + const chunk = chunks[chunkIdx]; + if (chunk === undefined) { + throw new ProtocolMismatchError( + `google-genai parseResponse: groundingSupport references ` + + `chunk index ${String(chunkIdx)} but the response has only ` + + `${String(chunks.length)} grounding chunk(s).`, + raw, + ); + } + const web = chunk.web; + if (web === undefined) { + // Non-web chunk kinds (retrieved-context, document, etc.) + // have no `web.uri`/`web.title` to populate a + // CitationSource. Skipping rather than synthesizing keeps + // the citation faithful to the wire shape the parser + // actually models -- the schema admits non-web chunks + // implicitly so a wider chunk kind reaching the parser + // does not fail schema validation, but it has no defined + // mapping into `CitationSource` until its discriminator + // is modeled here. + continue; + } + const citation = { + type: "citation" as const, + citedText: segment.text, + source: { + uri: web.uri, + title: web.title, + }, + textOffset: { + start: segment.startIndex, + end: segment.endIndex, + }, + }; + out.push({ + type: "inference.citation", + seq, + data: { citation, index }, + }); + } + } +} + +// Map Gemini's `codeExecutionResult.outcome` enum onto the +// internal `CodeExecutionResultBlock.status` union. The switch is +// exhaustive over the three values Gemini documents today; an +// unknown outcome string surfaces as a `ProtocolMismatchError` +// naming the value verbatim rather than being bucketed into a +// fallback status. Adding a new outcome to this mapping is a +// deliberate code change, not an implicit acceptance of whatever +// Gemini sends next. +function outcomeToStatus( + outcome: string, + raw: unknown, +): "ok" | "error" | "aborted" | "timeout" { + switch (outcome) { + case "OUTCOME_OK": + return "ok"; + case "OUTCOME_FAILED": + return "error"; + case "OUTCOME_DEADLINE_EXCEEDED": + return "timeout"; + default: + throw new ProtocolMismatchError( + `google-genai parseResponse: unknown codeExecutionResult.outcome ` + + `${JSON.stringify(outcome)}; the mapping recognizes ` + + `OUTCOME_OK, OUTCOME_FAILED, OUTCOME_DEADLINE_EXCEEDED. ` + + `A new outcome value is a deliberate adapter change, not a ` + + `silent fallback.`, + raw, + ); + } +} + +function parseResponse( + sseData: string, + state: GeminiParserState, + source: LastCycleSource, +): InferenceEvent[] { + let parsed: unknown; + try { + parsed = JSON.parse(sseData); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new ProtocolMismatchError( + `google-genai parseResponse: malformed JSON in SSE data payload: ${message}`, + sseData, + ); + } + + const event = GeminiSSEEvent(parsed); + if (event instanceof type.errors) { + throw new ProtocolMismatchError( + `google-genai parseResponse: SSE event failed schema validation: ${event.summary}`, + parsed, + ); + } + + const candidates = event.candidates ?? []; + + // The adapter's `buildRequest` never requests `candidateCount > 1`, + // so a multi-candidate response means the wire shape diverged from + // what was requested. Surface the mismatch loudly with the full + // payload in `error.raw` rather than silently picking `[0]`. + if (candidates.length > 1) { + throw new ProtocolMismatchError( + `google-genai parseResponse: expected at most one candidate, got ${String(candidates.length)}.`, + parsed, + ); + } + + // The seq field is a placeholder 0 -- the harness assigns real + // sequence numbers. + const seq = 0; + const out: InferenceEvent[] = []; + + const candidate = candidates[0]; + if (candidate?.content?.parts !== undefined) { + for (const part of candidate.content.parts) { + emitPart(part, state, seq, out, parsed); + } + } + + // `groundingMetadata` rides on the candidate alongside the parts + // and the finishReason. It is processed AFTER the parts have + // settled so any text deltas in the same event extend the + // currentBlock first; `emitGroundingCitations` reads the + // currentBlock's index to attribute each citation to the right + // text block. Citations precede the terminal usage emission -- + // they belong to the model's output, not to the bookkeeping + // signal that closes the response. + if (candidate?.groundingMetadata !== undefined) { + emitGroundingCitations( + candidate.groundingMetadata, + state, + seq, + out, + parsed, + ); + } + + // 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 + // and intermediate emissions would be pure noise that the + // harness's `inference.done` would discard anyway. + // + // `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. 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) { + throw new ProtocolMismatchError( + `google-genai parseResponse: terminal event (finishReason=${JSON.stringify(candidate.finishReason)}) missing usageMetadata.`, + parsed, + ); + } + const tokenUsage: TokenUsage = { + input: usage.promptTokenCount ?? 0, + output: usage.candidatesTokenCount ?? 0, + // Gemini exposes context caching via `cachedContentTokenCount` + // (single counter; the API does not distinguish "read" from + // "write" the way Anthropic does). The plain-text path does + // not exercise caching, so the field is absent here. A future + // caching commit decides whether to route the count into + // `cacheRead` or carry both fields. + cacheRead: usage.cachedContentTokenCount ?? 0, + cacheWrite: 0, + thinking: usage.thoughtsTokenCount ?? 0, + }; + out.push({ + type: "inference.usage", + seq, + data: { usage: tokenUsage, source }, + }); + + // Terminal events seal the response. A still-pending + // code-execution request at this point would mean Gemini + // emitted an executableCode part without a matching + // codeExecutionResult before stopping -- a wire bug, not a + // case the harness should silently swallow. + if (state.pendingExecutionRequestId !== null) { + throw new ProtocolMismatchError( + `google-genai parseResponse: response terminated with an ` + + `unmatched code-execution request ` + + `${JSON.stringify(state.pendingExecutionRequestId)}; the wire ` + + `must deliver a codeExecutionResult part before the terminal ` + + `finishReason.`, + parsed, + ); + } + } + + 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 { + 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.ts b/vendor/intx/inference/src/providers/index.ts new file mode 100644 index 000000000..c615b1b29 --- /dev/null +++ b/vendor/intx/inference/src/providers/index.ts @@ -0,0 +1,71 @@ +import { createAdapterRegistry } from "../adapter"; +import type { AdapterFactory, AdapterRegistry } from "../adapter"; +import { createDependencies, type Dependencies } from "../harness"; +import { loadAdapterFactories } from "../manifest"; +import type { AdapterManifest, ModuleImporter } from "../manifest"; +import { createAnthropicAdapter } from "./anthropic"; +import { createGoogleGenAIAdapter } from "./google-genai"; +import { createOpenAIAdapter } from "./openai"; + +export { + createAnthropicAdapter, + AnthropicQuirks, + ADAPTIVE_THINKING_MODELS, + ADAPTIVE_THINKING_EFFORT, +} from "./anthropic"; +export { createGoogleGenAIAdapter, GoogleGenAIQuirks } from "./google-genai"; +export { createOpenAIAdapter, OpenAIQuirks } from "./openai"; + +function builtinFactories(): Record { + return { + anthropic: createAnthropicAdapter, + openai: createOpenAIAdapter, + "openai-compatible": createOpenAIAdapter, + "google-genai": createGoogleGenAIAdapter, + }; +} + +/** + * Builds a registry of the adapters this package ships with, statically linked + * and resolved synchronously. The per-call factory invariant (a fresh adapter + * minted on every `resolve`) lives in {@link createAdapterRegistry}. + * + * @returns A registry resolving the built-in providers + */ +export function createBuiltinRegistry(): AdapterRegistry { + return createAdapterRegistry(builtinFactories()); +} + +/** + * Construct runtime dependencies wired to the built-in adapter registry. This + * is the honest zero-arg default for hosts that need the shipped provider set: + * it binds `globalThis.fetch` and the production scheduler via + * {@link createDependencies}. Hosts with custom adapters build a registry + * through {@link loadAdapterRegistry} and pass it to `createDependencies`. + * + * @returns Dependencies resolving the built-in providers + */ +export function createDefaultDependencies(): Dependencies { + return createDependencies(createBuiltinRegistry()); +} + +/** + * Builds a registry of the built-in adapters merged with custom adapters loaded + * from an operator-configured manifest. Custom adapters override built-ins + * sharing a provider key. With an empty manifest this returns just the + * built-ins. The per-call factory invariant lives in + * {@link createAdapterRegistry}. + * + * @param manifest - Validated custom adapter manifest entries + * @param opts - Optional injected module importer + * @returns A registry resolving built-in and custom providers + */ +export async function loadAdapterRegistry( + manifest: AdapterManifest, + opts?: { import?: ModuleImporter }, +): Promise { + return createAdapterRegistry({ + ...builtinFactories(), + ...(await loadAdapterFactories(manifest, opts)), + }); +} diff --git a/vendor/intx/inference/src/providers/openai.ts b/vendor/intx/inference/src/providers/openai.ts new file mode 100644 index 000000000..428d2d481 --- /dev/null +++ b/vendor/intx/inference/src/providers/openai.ts @@ -0,0 +1,1106 @@ +import { type } from "arktype"; + +import type { + ConversationTurn, + ContentBlock, + InferenceEvent, + InferenceOptions, + LastCycleSource, + 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"; +import { + decodeToolName, + encodeToolName, + type ToolNameLimit, +} from "../tool-name"; + +// OpenAI's function-name constraint is `^[a-zA-Z0-9_-]{1,64}$`; the +// OpenAI-compatible backends this adapter also serves (DeepSeek, Kimi) share +// that charset and reject the raw package-qualified names outright. +const OPENAI_TOOL_NAME_LIMIT: ToolNameLimit = { + provider: "openai", + 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 +// --------------------------------------------------------------------------- + +function buildRequest( + messages: ConversationTurn[], + model: string, + options: InferenceOptions, + quirks: ResolvedOpenAIQuirks, +): BuiltRequest { + const convertedMessages: unknown[] = messages.flatMap((msg) => + toOpenAIMessage(msg, quirks.forceAssistantReasoningContent), + ); + + const body: Record = { + model, + [quirks.maxTokensField]: options.maxTokens ?? 4096, + messages: convertedMessages, + stream: true, + }; + + if (options.temperature !== undefined) { + body["temperature"] = options.temperature; + } + + if (options.tools !== undefined && options.tools.length > 0) { + body["tools"] = options.tools.map((t) => ({ + type: "function", + function: { + name: encodeToolName(t.name, OPENAI_TOOL_NAME_LIMIT), + description: t.description, + 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) { + // Prepend a system message if provided via options (takes priority over + // any system messages already in the history). + body["messages"] = [ + { role: "system", content: options.systemPrompt }, + ...convertedMessages, + ]; + } + + if (options.responseFormat !== undefined) { + body["response_format"] = toOpenAIResponseFormat(options.responseFormat); + } + + return { + url: "/chat/completions", + headers: { + "content-type": "application/json", + authorization: BEARER_CREDENTIAL_SENTINEL, + }, + body: JSON.stringify(body), + }; +} + +// Translate the internal `responseFormat` union to OpenAI's +// `response_format` field. The three kinds map one-to-one to OpenAI's +// `text` / `json_object` / `json_schema` types; in `json-schema` mode +// the caller's `name`, `schema`, and (optional) `strict` ride through +// verbatim. Strict mode is the path that produces structured `refusal` +// responses when the model declines a request -- the response-side +// parser handles those refusal chunks below. +function toOpenAIResponseFormat( + format: NonNullable, +): Record { + switch (format.kind) { + case "text": + return { type: "text" }; + case "json": + return { type: "json_object" }; + case "json-schema": { + const jsonSchema: Record = { + name: format.name, + schema: format.schema, + }; + if (format.strict !== undefined) jsonSchema["strict"] = format.strict; + return { type: "json_schema", json_schema: jsonSchema }; + } + } +} + +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") + .map((b) => b.text) + .join("\n\n"); + return [{ role: "system", content: text }]; + } + + if (msg.role === "user") { + // Check if any block is a tool result — if so, emit as tool role messages. + const toolResults = msg.content.filter( + (b): b is Extract => + b.type === "tool_result", + ); + if (toolResults.length > 0) { + // One tool role message per result. The OpenAI Chat Completions schema + // for `role: "tool"` only permits role/tool_call_id/content — there is + // no `is_error` field — so error status is encoded inside `content`. + return toolResults.map((r) => { + const text = r.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + return { + role: "tool", + tool_call_id: r.callId, + content: r.isError ? `\n${text}\n` : text, + }; + }); + } + + const parts = msg.content.map(toOpenAIContentPart); + // If all parts are plain strings, collapse to a single string. + if (parts.every((p) => typeof p === "string")) { + return [{ role: "user", content: parts.join("") }]; + } + // 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") { + // Detect block types that cannot survive the OpenAI assistant + // message shape and surface the failure rather than silently + // dropping them. Code execution blocks are first-class semantic + // content; their loss would corrupt cross-provider conversations. + // RefusalBlocks are this adapter's own output (delta.refusal + // accumulates into one) but the round-trip back through history + // is not modeled — a silent drop would erase the refusal text on + // any continuation request, so the marshaling fails loudly + // alongside code_execution. + for (const block of msg.content) { + if ( + block.type === "code_execution_request" || + block.type === "code_execution_result" || + block.type === "refusal" + ) { + throw new Error( + `OpenAI adapter does not handle ${block.type} content blocks.`, + ); + } + } + 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", + ); + const toolCalls = msg.content.filter( + (b): b is Extract => + 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 (textContent.length > 0) { + result["content"] = textContent; + } else { + result["content"] = null; + } + + // 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) => ({ + id: tc.id, + type: "function", + function: { + name: encodeToolName(tc.name, OPENAI_TOOL_NAME_LIMIT), + arguments: JSON.stringify(tc.arguments), + }, + })); + } + + return [result]; + } + + 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": + return block.text; + case "image": { + const source = block.source; + if (source.kind === "base64") { + return { + type: "image_url", + image_url: { + url: `data:${source.mimeType};base64,${source.data}`, + }, + }; + } + if (source.kind === "url") { + // OpenAI's image_url accepts a public URL verbatim alongside + // the data-URL form. The MediaSource's mimeType is not + // propagated on the wire — OpenAI infers content type from + // the URL response. The internal mimeType requirement still + // keeps the caller honest about what they have in hand. + return { + type: "image_url", + image_url: { + url: source.url, + }, + }; + } + if (source.kind === "file-reference") { + // OpenAI's Chat Completions endpoint accepts images only via + // `image_url: { url }` (data URL or public URL). It does not + // accept opaque uploaded-file references the way Anthropic's + // `{ type: "file", file_id }` does. A `file-reference` + // handle minted by some other provider (an Anthropic file_id, + // a Gemini fileUri) is meaningless to OpenAI; the adapter + // would have to round-trip the bytes through base64 to be + // useful, which is a caller-level choice, not an adapter one. + // Surface the constraint loudly with the apparent reference + // so an operator triaging the failure sees what was sent. + throw new Error( + `OpenAI Chat Completions does not accept file-reference image ` + + `sources; the API only takes base64 data URLs or public URLs ` + + `via image_url. Received reference: ${source.reference}`, + ); + } + source satisfies never; + throw new Error(`unreachable: unknown MediaSource kind`); + } + case "audio": + case "video": + throw new Error( + `OpenAI adapter does not yet handle ${block.type} content blocks.`, + ); + 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 + // active conversation state the next turn needs to make sense + // of. OpenAI's Chat Completions has no input wire shape for + // citations either, so re-uploading them on a follow-up turn + // would be ignored at best. Drop them when serializing history + // to OpenAI; a downstream consumer that wants to preserve them + // across provider switches reads the finalized turn's content[] + // 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 + // dropping them would lose the model's tool invocation entirely. + // OpenAI has no first-class code execution surface today. + throw new Error( + `OpenAI adapter does not handle ${block.type} content blocks.`, + ); + case "thinking": + // Thinking blocks are not forwarded to OpenAI endpoints. + return ""; + case "redacted_thinking": + // Redacted thinking blocks are opaque by design; the cross- + // provider mapping is meaningless on OpenAI's surface. + return ""; + case "tool_call": + case "tool_result": + // These are handled separately in toOpenAIMessage. + return ""; + case "refusal": + // RefusalBlocks are output-only (delta.refusal accumulates into + // one). Echoing one back inside a user-role content array has + // no defined OpenAI wire shape; fail at the marshaling + // boundary rather than silently emit `null` part bytes that + // would round-trip as an unrecognized fragment. + throw new Error("OpenAI adapter does not handle refusal content blocks."); + } +} + +// --------------------------------------------------------------------------- +// Response parsing +// --------------------------------------------------------------------------- + +const EMPTY_PARTIAL: PartialMessage = { text: "" }; + +// Fireworks (and likely other OpenAI-compatible deployments) emits +// `name: null` and `arguments: null` on tool-call delta fragments AFTER +// the start delta. arktype rejects `null` against `"string"` and would +// drop the whole chunk silently — taking the argument fragments with +// it. Accept `string | null` here and treat null the same as the field +// being absent at the consumer site. +const OpenAIToolCallDelta = type({ + "index?": "number", + "id?": "string | null", + "function?": { + "name?": "string | null", + "arguments?": "string | null", + }, +}); + +const OpenAIChunkDelta = type({ + "role?": "string", + "content?": "string | null", + "reasoning_content?": "string | null", + "reasoning?": "string | null", + // Strict-mode structured-outputs refusal: when the model declines a + // JSON-schema request on policy grounds, the delta carries the + // refusal text in this field instead of `content`. Some + // OpenAI-compatible relays strip it before forwarding; the parser + // emits refusal events only when the field is present. + "refusal?": "string | null", + "tool_calls?": OpenAIToolCallDelta.array(), +}); + +const PromptTokensDetails = type({ "cached_tokens?": "number" }).or("null"); +const CompletionTokensDetails = type({ + "reasoning_tokens?": "number", +}).or("null"); + +const OpenAIChunkUsage = type({ + "prompt_tokens?": "number", + "completion_tokens?": "number", + "prompt_tokens_details?": PromptTokensDetails, + "completion_tokens_details?": CompletionTokensDetails, +}); + +const OpenAIChunk = type({ + "choices?": type({ + "index?": "number", + delta: OpenAIChunkDelta, + "finish_reason?": "string | null", + }).array(), + "usage?": OpenAIChunkUsage.or("null"), +}); + +// Per-request state for the OpenAI parser. OpenAI's Chat Completions +// has no wire-level content_block index — reasoning_content, content, +// and tool_calls all appear as fields on the same delta chunk +// without per-block positional indices. The harness's per-index +// routing nevertheless requires distinct indices for distinct +// content blocks at distinct positions, so the parser assigns block +// indices on first observation in arrival order, threaded through +// this shared counter. Tool calls share the same counter to avoid +// colliding with text/thinking indices: a tool_call that arrives +// before any text gets the next free block index, NOT zero, so the +// later text doesn't try to land on top of it. +// +// `tcDelta.index` (OpenAI's position in `tool_calls[]`) is a +// tool-call-local index, distinct from a content-block index. The +// indexer maintains a `toolCallBlockIndex` map from tcDelta.index to +// the block index assigned at first observation; subsequent deltas +// for the same tcDelta.index reuse it. +type OpenAIBlockIndexer = { + nextIndex: number; + textIndex: number | null; + thinkingIndex: number | null; + refusalIndex: number | null; + toolCallBlockIndex: Map; +}; + +function getOrAssignTextIndex(state: OpenAIBlockIndexer): number { + if (state.textIndex === null) { + state.textIndex = state.nextIndex; + state.nextIndex += 1; + } + return state.textIndex; +} + +function getOrAssignThinkingIndex(state: OpenAIBlockIndexer): number { + if (state.thinkingIndex === null) { + state.thinkingIndex = state.nextIndex; + state.nextIndex += 1; + } + return state.thinkingIndex; +} + +function getOrAssignRefusalIndex(state: OpenAIBlockIndexer): number { + if (state.refusalIndex === null) { + state.refusalIndex = state.nextIndex; + state.nextIndex += 1; + } + return state.refusalIndex; +} + +function getOrAssignToolCallIndex( + state: OpenAIBlockIndexer, + toolCallIndex: number, +): number { + const existing = state.toolCallBlockIndex.get(toolCallIndex); + if (existing !== undefined) return existing; + const assigned = state.nextIndex; + state.nextIndex += 1; + state.toolCallBlockIndex.set(toolCallIndex, assigned); + 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 + // JSON.parse failure or an arktype rejection means the upstream + // emitted bytes that violate the OpenAI streaming protocol — a + // protocol mismatch, not a transport flake. Surface it through the + // harness's stream-error catch via ProtocolMismatchError so the + // resulting inference.error carries category "protocol_mismatch" + // and the offending data in error.raw, instead of silently dropping + // the chunk and leaving the agent to guess why a tool call arrived + // with empty arguments. + let parsed: unknown; + try { + parsed = JSON.parse(sseData); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new ProtocolMismatchError( + `openai parseResponse: malformed JSON in SSE data payload: ${message}`, + sseData, + ); + } + + const chunk = OpenAIChunk(parsed); + if (chunk instanceof type.errors) { + throw new ProtocolMismatchError( + `openai parseResponse: SSE chunk failed schema validation: ${chunk.summary}`, + parsed, + ); + } + + const seq = 0; + + const { choices } = chunk; + if (choices === undefined || choices.length === 0) { + // Check for usage-only events (some providers send a final event with usage). + const { usage } = chunk; + if (usage != null) { + return [ + { + type: "inference.usage", + seq, + data: { usage: toInferenceUsage(usage), source }, + }, + ]; + } + return []; + } + + const choice = choices[0]; + if (choice === undefined) return []; + const { delta } = choice; + + const events: InferenceEvent[] = []; + + // Providers stream reasoning tokens under different field names: + // 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 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", + seq, + data: { + token: reasoning, + partial: EMPTY_PARTIAL, + index: getOrAssignThinkingIndex(indexer), + }, + }); + } + + const { content } = delta; + if (typeof content === "string" && content.length > 0) { + events.push({ + type: "inference.text.delta", + seq, + data: { + token: content, + partial: EMPTY_PARTIAL, + index: getOrAssignTextIndex(indexer), + }, + }); + } + + // Strict-mode structured-outputs refusal. Allocate a content-block + // index via the same shared counter that text/thinking/tool_call use + // so a refusal that arrives interleaved with text (e.g. partial + // content emitted before the refusal kicks in) lands on its own + // block index rather than colliding with text. + const { refusal } = delta; + if (typeof refusal === "string" && refusal.length > 0) { + events.push({ + type: "inference.refusal.delta", + seq, + data: { + token: refusal, + partial: EMPTY_PARTIAL, + index: getOrAssignRefusalIndex(indexer), + }, + }); + } + + const { tool_calls: toolCallDeltas } = delta; + + if (toolCallDeltas !== undefined) { + for (const tcDelta of toolCallDeltas) { + const toolCallSlot = tcDelta.index ?? 0; + // The harness's per-index map keys on content-block index, not + // OpenAI's `tool_calls[]` slot. Map this tool call's slot to a + // content-block index that doesn't collide with text/thinking: + // first observation of each unique `tcDelta.index` allocates a + // fresh content-block index from the shared `nextIndex` + // counter; subsequent deltas for the same slot reuse it. + const blockIndex = getOrAssignToolCallIndex(indexer, toolCallSlot); + // Normalize null → undefined: Fireworks emits literal null on every + // delta after the first; we treat that the same as the field being + // absent so the start / fragment branches below remain simple. + const id = tcDelta.id ?? undefined; + const fn = tcDelta.function; + const wireName = fn?.name ?? undefined; + const name = + wireName !== undefined ? decodeToolName(wireName) : undefined; + const argFragment = fn?.arguments ?? undefined; + + // Different providers shape these deltas differently: + // - OpenAI emits id + name + empty arguments in the first delta, + // then arguments-only deltas (no id, no name) for the body. + // - Fireworks (kimi-k2.6) emits id + index on EVERY delta, with + // name populated only on the first and arguments fragments on + // subsequent deltas. The non-first deltas carry name: null + // (normalized to undefined above) rather than omitting the + // field outright. + // Treat the two signals independently. A single delta may legitimately + // carry both a start signal (id + non-null name) and an argument + // fragment; both must be emitted. + // + // `data.callId` is the OpenAI-provided id when present + // (`tcDelta.id`); when absent on continuation deltas, the + // adapter synthesizes a per-stream placeholder from + // `toolCallSlot` so the harness's id-keyed accumulator can + // merge fragments until the real id resolves at finalize time. + // `data.index` is the content-block index allocated above — + // namespaced into the same counter as text/thinking indices so + // a tool_call arriving before any text doesn't collide with a + // later text block at the same numeric index. + if (id !== undefined && name !== undefined) { + events.push({ + type: "inference.tool_call.start", + seq, + data: { + callId: id, + name, + partial: EMPTY_PARTIAL, + index: blockIndex, + }, + }); + } + if (argFragment !== undefined && argFragment.length > 0) { + // The delta's `callId` is a per-stream placeholder used by the + // harness to resolve fragments to the real id minted on the + // start event. Use `String(blockIndex)` rather than + // `String(toolCallSlot)` so the placeholder matches the key + // the harness registers in `indexToCallId` on start — + // otherwise a non-zero, non-contiguous `tcDelta.index` + // (single tool at slot 3, or parallel tools at slots 0/3) + // would land its fragments under a key the harness never + // registered, and the harness's accumulator would silently + // drop them. + events.push({ + type: "inference.tool_call.delta", + seq, + data: { + callId: String(blockIndex), + argumentFragment: argFragment, + partial: EMPTY_PARTIAL, + index: blockIndex, + }, + }); + } + } + } + + // finish_reason is checked but we emit nothing — the harness handles cleanup. + // (Keeping the reference here documents the field is intentionally unused.) + void choice.finish_reason; + + // Usage at end of stream (stream_options: { include_usage: true }). + const usageInChunk = chunk.usage; + if (usageInChunk != null) { + events.push({ + type: "inference.usage", + seq, + 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; +} + +function extractRetryAfterMs(headers: Headers): number | undefined { + // OpenAI's non-standard millisecond header takes priority + const retryMs = headers.get("retry-after-ms"); + if (retryMs !== null) { + const ms = Number(retryMs); + if (Number.isFinite(ms) && ms > 0) return Math.ceil(ms); + } + const raw = headers.get("retry-after"); + if (raw !== null) { + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds > 0) { + return Math.ceil(seconds * 1000); + } + } + return undefined; +} + +function extractPacingDelayMs(headers: Headers): number | undefined { + const remaining = headers.get("x-ratelimit-remaining-requests"); + if (remaining === null) return undefined; + const n = Number(remaining); + if (!Number.isFinite(n) || n > 0) return undefined; + + const reset = headers.get("x-ratelimit-reset-requests"); + if (reset === null) return undefined; + const ms = parseDuration(reset); + return ms !== undefined && ms > 0 ? ms : undefined; +} + +function parseDuration(value: string): number | undefined { + let total = 0; + const pattern = /(\d+(?:\.\d+)?)(ms|s|m|h)/g; + let match; + while ((match = pattern.exec(value)) !== null) { + const num = Number(match[1]); + switch (match[2]) { + case "ms": + total += num; + break; + case "s": + total += num * 1000; + break; + case "m": + total += num * 60_000; + break; + case "h": + total += num * 3_600_000; + break; + } + } + return total > 0 ? Math.ceil(total) : undefined; +} + +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. + // content streams in arrival order. + const indexer: OpenAIBlockIndexer = { + nextIndex: 0, + textIndex: null, + thinkingIndex: null, + refusalIndex: null, + toolCallBlockIndex: new Map(), + }; + return { + 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.ts b/vendor/intx/inference/src/reactor.ts new file mode 100644 index 000000000..9c6cb9542 --- /dev/null +++ b/vendor/intx/inference/src/reactor.ts @@ -0,0 +1,1707 @@ +// Agent reactor: the event-driven dispatch loop. +// +// The reactor processes one event at a time, asks the director for the next +// action, validates the action set, and executes. It manages the streaming +// harness for inference, dispatches tool calls, handles gates and correlation, +// and emits all session events with monotonic sequence numbers. +// +// Suspension semantics: when the director returns a suspend action, the reactor +// registers the gate and continues processing events. Inbound messages during +// suspension reach the director as message.received events (director decides: +// queue, fork, or ignore). When the gate clears, a reactor.gate.cleared event +// is enqueued and the director gets to decide next steps. +// +// (INFERENCE.md § Agent Reactor) + +import type { + InboundMessage, + InferenceEvent, + InferenceOptions, + InferenceSource, + ReactorDirector, + ReactorInboundEvent, + ContextStore, + ToolRunner, + TokenUsage, + ConversationTurn, + ToolResult, + ToolCall, + AbortReason, + BeforeToolDecision, + BeforeToolExtension, + GateType, + PendingOperation, + ReactorAction, + ToolResultTransform, + ContextTransform, + Compactor, + TransformRecord, + StrategyContext, + StrategyResult, +} from "@intx/types/runtime"; + +import { getLogger } from "@intx/log"; +import { ApprovalDecision, signalKindToGateType } from "@intx/types"; +import { canonicalJsonStringify } from "@intx/types/wire-definition-hash"; +import { type } from "arktype"; +import { runInference } from "./harness"; +import type { Dependencies, InferenceHarnessOptions } from "./harness"; +import { createCapabilities } from "./director"; +import { createGateManager } from "./gates"; +import { createCorrelationRegistry } from "./correlation"; +import { createStateManager } from "./state"; +import { validateActions } from "./actions"; +import { + createToolResultTurn, + createInboundTurn, + assertWellFormedToolSequence, +} from "./turns"; +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)}`); +} + +function buildHarnessOpts( + turns: ConversationTurn[], + source: InferenceSource, + options: InferenceOptions | undefined, + signal: AbortSignal, + nextSeq: () => number, + deps: Dependencies, +): InferenceHarnessOptions { + if (options !== undefined) { + return { + turns, + source, + inferenceOptions: options, + signal, + nextSeq, + deps, + }; + } + return { turns, source, signal, nextSeq, deps }; +} + +export type ReactorEmittedEvent = + | InferenceEvent + | { + type: "message.received"; + seq: number; + data: { message: InboundMessage }; + }; + +export type ReactorConfig = { + sessionId: string; + director: ReactorDirector; + source: InferenceSource; + /** + * Fail over `source` to the next entry in the priority-ordered source + * list, in place, returning false at the end of the list. When omitted the + * reactor runs the single active source with no failover. + */ + failOverToNextSource?: () => boolean; + /** Reset `source` to the most-preferred source, in place. */ + resetToPreferredSource?: () => void; + toolRunner: ToolRunner; + contextStore: ContextStore; + correlationValidator?: CorrelationValidator; + onEvent: (event: ReactorEmittedEvent) => void; + deps: Dependencies; + inferenceRunner?: ( + opts: InferenceHarnessOptions, + ) => AsyncGenerator; + beforeToolExtensions?: BeforeToolExtension[]; + toolResultTransforms?: ToolResultTransform[]; + contextTransforms?: ContextTransform[]; + compactors?: Record; + afterCheckpoint?: () => Promise; + onShutdown?: () => Promise; + gateTimeout?: number; + shutdownTimeoutMs?: number; + /** + * Number of consecutive identical tool-call turns that trips doom-loop + * detection. A turn's identity is its batch of executed tool calls; a + * runaway model repeating the same call burns inference cost with no + * progress. On the Nth consecutive identical turn the reactor emits a fatal + * `reactor.error` and shuts the run down. Must be a positive integer. + * Pass `false` to disable doom-loop detection entirely. Defaults to + * `DEFAULT_DOOM_LOOP_THRESHOLD`. + */ + doomLoopThreshold?: number | false; +}; + +export type Reactor = { + /** Begin processing. Emits reactor.start. Must be called exactly once. */ + start(): void; + /** Inject an inbound message into the reactor. */ + deliver(message: InboundMessage): void; + /** Initiate graceful shutdown with a reason. */ + abort(reason: AbortReason): void; +}; + +const DEFAULT_GATE_TIMEOUT_MS = 3_600_000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000; +const DEFAULT_DOOM_LOOP_THRESHOLD = 3; + +/** + * Resolve the caller-facing `doomLoopThreshold` into the reactor's internal + * form: a positive integer when detection is active, or `null` when it is + * disabled. `undefined` (omitted) takes the default; `false` disables; a + * number is validated here — the construction edge is the one place that owns + * the default and rejects a malformed value loudly, so a stray `0`, negative, + * or non-integer throws rather than silently disarming the guard. Downstream + * code compares against the returned `number | null` and never sees the raw + * `false`, whose numeric coercion would otherwise trip the loop immediately. + */ +function resolveDoomLoopThreshold( + raw: number | false | undefined, +): number | null { + if (raw === false) return null; + if (raw === undefined) return DEFAULT_DOOM_LOOP_THRESHOLD; + if (!Number.isInteger(raw) || raw < 1) { + throw new Error( + `doomLoopThreshold must be a positive integer or false, got ${String(raw)}`, + ); + } + return raw; +} + +/** + * Order-independent identity of a batch of executed tool calls. Each call + * canonicalizes to its name and arguments (the call `id` is excluded, since it + * differs on every request); sorting makes a parallel batch match regardless + * of the order the model emitted its calls. Two turns share a signature when + * they run the same multiset of `(name, arguments)` pairs. + */ +function toolBatchSignature(calls: ToolCall[]): string { + return calls + .map((call) => + canonicalJsonStringify({ name: call.name, arguments: call.arguments }), + ) + .sort() + .join("\n"); +} + +/** + * Creates a reactor instance bound to the given configuration. + * Call `start()` to begin the event loop. + */ +export function createReactor(config: ReactorConfig): Reactor { + const { + sessionId, + director, + toolRunner, + contextStore, + correlationValidator, + onEvent, + deps, + inferenceRunner = runInference, + beforeToolExtensions = [], + toolResultTransforms = [], + contextTransforms = [], + compactors = {}, + afterCheckpoint, + onShutdown, + gateTimeout = DEFAULT_GATE_TIMEOUT_MS, + shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS, + // Resolve the optional failover hooks once here, at the reactor's + // construction edge. A reactor with no source list fails over to + // nothing and resets to a no-op, so the inference loop below runs the + // single active source exactly as before. + failOverToNextSource = () => false, + resetToPreferredSource = () => { + /* single-source: nothing to reset */ + }, + } = config; + + // Resolved once at the construction edge: a positive integer while detection + // is active, or `null` when the caller disabled it with `false`. Every + // downstream comparison reads this binding, never the raw config value. + const doomLoopThreshold = resolveDoomLoopThreshold(config.doomLoopThreshold); + + // Monotonic sequence counter, scoped to this session. + let seq = 0; + function nextSeq(): number { + return ++seq; + } + + function emit(event: ReactorEmittedEvent): void { + onEvent(event); + } + + // Inbound event queue. Events are pushed here and drained by the loop. + const queue: ReactorInboundEvent[] = []; + let queueResolve: (() => void) | null = null; + + // A tool cycle spans from the moment the reactor dispatches an inference or + // a tool batch until the director has consumed every completion event that + // operation produces. While a cycle is in flight, admitting a new inbound + // message — and the inference it triggers — ahead of the outstanding + // completion events corrupts the prompt: an assistant tool_call turn must be + // immediately followed by its tool results, and a new inference would + // instead interleave fresh turns and re-infer against a half-finished batch, + // which providers reject. + // + // pendingContinuations is the authoritative count of dispatched operations + // whose completion events have not yet been consumed. Every cycle event is + // counted as it is enqueued and uncounted as it is dequeued, so the count + // always equals the number of cycle events waiting in the queue. While it is + // positive, dequeueNext drains cycle events ahead of inbound mail; at zero + // the cycle is quiescent and processing reverts to FIFO. + // + // An earlier design inferred "mid-cycle" from history shape — whether the + // last turn was an assistant tool_call turn. That underreports in-flight + // work: a finished tool batch appends its tool-result turn to history before + // its tool.done events are consumed, flipping the last turn away from the + // assistant tool_call turn while completion events are still queued, which + // let inbound mail start an overlapping inference. + const CYCLE_EVENT_TYPES = new Set([ + "inference.done", + "inference.error", + "tool.done", + ]); + + let pendingContinuations = 0; + + function enqueue(event: ReactorInboundEvent): void { + if (CYCLE_EVENT_TYPES.has(event.type)) { + pendingContinuations += 1; + } + queue.push(event); + if (queueResolve !== null) { + const resolve = queueResolve; + queueResolve = null; + resolve(); + } + } + + async function waitForEvent(): Promise { + if (queue.length > 0) return; + await new Promise((resolve) => { + queueResolve = resolve; + }); + } + + function dequeueNext(): ReactorInboundEvent | undefined { + if (queue.length === 0) return undefined; + + // Always process abort immediately. + const abortIdx = queue.findIndex((e) => e.type === "abort"); + if (abortIdx !== -1) { + return queue.splice(abortIdx, 1)[0]; + } + + // Mid-cycle: drain inference-cycle events before anything else so the + // outstanding inference or tool batch completes before new mail can start + // an overlapping inference. + if (pendingContinuations > 0) { + const idx = queue.findIndex((e) => CYCLE_EVENT_TYPES.has(e.type)); + if (idx !== -1) { + return queue.splice(idx, 1)[0]; + } + } + + return queue.shift(); + } + + const gates = createGateManager(); + const correlations = createCorrelationRegistry(); + const capabilities = createCapabilities(); + + let stateManager: ReturnType | null = null; + let running = false; + let done = false; + let shutdownStarted = false; + // Correlation state is empty until context loading and gate rehydration + // finish. Hold early deliveries so a resumed approval cannot be mistaken + // for a new conversation message during that startup window. + let startupDeliveries: InboundMessage[] | null = []; + + // Per-message run-bracket state. Set when the loop dequeues a + // message.received and begins per-message work; cleared at the + // terminal point (wait/reply/done) or at a reactor-fatal abandon. + // `messageRunId` is reactor-minted per dequeue via crypto.randomUUID + // so a crash-and-replay that re-delivers the same messageId still + // produces unambiguous start/end pairs downstream. + let currentMessageRunId: string | null = null; + let currentMessageId: string | null = null; + + // Doom-loop detection state, scoped to the current message run. Each executed + // tool-call turn is reduced to a batch signature; consecutive identical + // signatures accumulate here, and the run is broken when the count reaches + // `doomLoopThreshold`. This is run-scoped, not cycle-scoped: it resets only in + // `openMessageRun`, never in `resetCycleAccumulators`. It is also deliberately + // ephemeral (closure state, not persisted) -- a mid-run restart resets it to + // zero and the loop simply re-accumulates and trips a few turns later. + let lastToolBatchSignature: string | null = null; + let toolBatchRepeatCount = 0; + let lastToolBatchNames: string[] = []; + + function openMessageRun(messageId: string): void { + currentMessageRunId = crypto.randomUUID(); + currentMessageId = messageId; + lastToolBatchSignature = null; + toolBatchRepeatCount = 0; + lastToolBatchNames = []; + emit({ + type: "message.run.started", + seq: nextSeq(), + data: { + messageId, + messageRunId: currentMessageRunId, + receivedAt: Date.now(), + }, + }); + } + + function closeMessageRun( + status: "completed" | "failed", + error?: { message: string; kind?: string }, + ): void { + if (currentMessageRunId === null || currentMessageId === null) return; + const data: { + messageRunId: string; + messageId: string; + status: "completed" | "failed"; + error?: { message: string; kind?: string }; + } = { + messageRunId: currentMessageRunId, + messageId: currentMessageId, + status, + }; + if (error !== undefined) data.error = error; + emit({ type: "message.run.ended", seq: nextSeq(), data }); + currentMessageRunId = null; + currentMessageId = null; + } + + // Per-cycle accumulator of TransformRecord entries produced by every + // transform invocation (tool result, context, compactor). Flushed via + // contextStore.writeManifest at cycle boundaries. + let manifestBuffer: TransformRecord[] = []; + + // Tracks how the current cycle should be summarized in the commit message. + 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; + + // AbortController for in-flight inference/tool operations. + let operationController = new AbortController(); + + function abortOperations(): void { + operationController.abort(); + operationController = new AbortController(); + } + + // Track in-flight inference and tool promises for shutdown cleanup. + const inFlight = new Set>(); + + function track(p: Promise): Promise { + inFlight.add(p); + p.then( + () => inFlight.delete(p), + () => inFlight.delete(p), + ); + return p; + } + + // ------------------------------------------------------------------------- + // Correlation helper + // ------------------------------------------------------------------------- + + // Guard against concurrent tryCorrelate calls for the same correlationId. + // deliver() is fire-and-forget async, so two rapid delivers can interleave + // 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; + + if (correlatingIds.has(correlationId)) return false; + const pending = correlations.lookup(correlationId); + if (pending === undefined) return false; + + correlatingIds.add(correlationId); + + if (correlationValidator !== undefined) { + let valid: boolean; + try { + valid = await correlationValidator.validate(pending, message); + } catch (cause) { + logger.warn`Correlation validator threw for ${correlationId}: ${cause}`; + correlatingIds.delete(correlationId); + return false; + } + if (!valid) { + correlatingIds.delete(correlationId); + return false; + } + } + + // 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; + + let dispatch: ResumeDispatch; + try { + dispatch = resumePendingOperation(op, message); + } catch (cause) { + correlatingIds.delete(correlationId); + throw cause; + } + + 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; + } + } + + emit({ + type: "message.correlated", + seq: nextSeq(), + data: { message, correlationId }, + }); + + return true; + } + + // ------------------------------------------------------------------------- + // Action execution + // ------------------------------------------------------------------------- + + let pendingPacingDelayMs = 0; + + function buildStrategyContext(trigger: string): StrategyContext { + if (stateManager === null) { + throw new Error("State manager not initialized"); + } + return { state: stateManager.snapshot(), trigger }; + } + + async function persistBlobs( + blobs: StrategyResult["blobs"], + ): Promise { + if (blobs === undefined) return; + for (const blob of blobs) { + await contextStore.writeBlob(blob.key, blob.bytes, blob.contentType); + } + } + + async function executeInfer( + options: InferenceOptions | undefined, + ): Promise { + if (stateManager === null) return; + + const signal = operationController.signal; + + // Proactive pacing: if the previous inference response indicated we are + // at the rate limit, wait before sending the next request. + if (pendingPacingDelayMs > 0 && !signal.aborted) { + const delayMs = pendingPacingDelayMs; + pendingPacingDelayMs = 0; + logger.info`Pacing: waiting ${String(delayMs)}ms before next inference request`; + await new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + if (signal.aborted) return; + } + + // Run the context transform chain to produce the materialized prompt. + let prompt: ConversationTurn[] = stateManager.getTurns(); + for (const transform of contextTransforms) { + const ctx = buildStrategyContext("pre-inference"); + const result = await transform.apply(prompt, ctx); + prompt = result.output; + manifestBuffer.push(result.record); + await persistBlobs(result.blobs); + } + + // Tripwire: a malformed tool sequence is invalid in a coherent tool + // conversation and would otherwise surface as an opaque provider rejection. + // Catch it here, before the prompt is persisted or sent, so the corruption + // fails loud as an internal error at the assembly boundary. Throwing routes + // through the reactor's fatal-error path. + assertWellFormedToolSequence(prompt); + + try { + await contextStore.writePrompt(prompt); + } catch (cause) { + logger.error`writePrompt failed: ${cause}`; + emitError( + `writePrompt failed: ${cause instanceof Error ? cause.message : String(cause)}`, + false, + ); + } + + const p = (async () => { + // Each cycle starts at the most-preferred source; a failover in a + // prior cycle must not leave the agent permanently demoted. + resetToPreferredSource(); + + for (;;) { + const harnessOpts = buildHarnessOpts( + prompt, + config.source, + options, + signal, + nextSeq, + deps, + ); + + let lastDone: + | Extract + | undefined; + let lastError: + | Extract + | undefined; + + for await (const event of inferenceRunner(harnessOpts)) { + emit(event); + if (event.type === "inference.done") lastDone = event; + else if (event.type === "inference.error") lastError = event; + } + + if (lastDone !== undefined) { + if (stateManager !== null) { + stateManager.appendTurn(lastDone.data.turn); + stateManager.accumUsage(lastDone.data.usage); + stateManager.setLastCycleUsage(lastDone.data.usage); + stateManager.setLastCycleSource(lastDone.data.source); + } + cycleInferred = true; + try { + await contextStore.writeResponse(lastDone.data.turn); + } catch (cause) { + logger.error`writeResponse failed: ${cause}`; + emitError( + `writeResponse failed: ${cause instanceof Error ? cause.message : String(cause)}`, + false, + ); + } + if (lastDone.data.pacingDelayMs !== undefined) { + pendingPacingDelayMs = lastDone.data.pacingDelayMs; + } + const u = lastDone.data.usage; + logger.info`Inference usage: input=${String(u.input)} output=${String(u.output)} cacheRead=${String(u.cacheRead)} cacheWrite=${String(u.cacheWrite)}${lastDone.data.pacingDelayMs !== undefined ? ` pacing=${String(lastDone.data.pacingDelayMs)}ms` : ""}`; + enqueue({ + type: "inference.done", + turn: lastDone.data.turn, + usage: lastDone.data.usage, + source: lastDone.data.source, + }); + return; + } + + if (lastError === undefined) { + emitError("Inference runner returned without a terminal event", true); + enqueue({ + type: "inference.error", + error: { + category: "fatal", + message: "Inference runner returned without a terminal event", + }, + partial: { text: "" }, + }); + return; + } + + const err = lastError.data.error; + const partial = lastError.data.partial; + + // Source-invariant failures: no source can serve this call, so + // abort the whole cycle rather than waste failover attempts. + if ( + err.category === "context_overflow" || + err.category === "fatal" || + err.category === "aborted" + ) { + enqueue({ type: "inference.error", error: err, partial }); + return; + } + + // 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}`; + continue; + } + + // No further source to fail over to: surface the last error. + enqueue({ type: "inference.error", error: err, partial }); + return; + } + })(); + + void track(p); + await p; + } + + async function executeTools( + calls: ToolCall[], + parallel: boolean, + addToHistory = true, + ): Promise { + if (stateManager === null) return; + const state = stateManager; + + const signal = operationController.signal; + + 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 decision: BeforeToolDecision; + try { + 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, + ); + decision = { type: "block", reason: msg }; + } + + 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: decision.reason, + isError: true, + }; + emit({ + type: "tool.done", + seq: nextSeq(), + data: { result: blocked }, + }); + return blocked; + } + } + + emit({ type: "tool.start", seq: nextSeq(), data: { call } }); + const rawResult = await toolRunner.run(call, signal); + emit({ type: "tool.done", seq: nextSeq(), data: { result: rawResult } }); + + if (rawResult.pendingMarker !== undefined && stateManager !== null) { + const marker = rawResult.pendingMarker; + const gateId = `pending-${marker.correlationId}`; + 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 + ? { expectedFrom: marker.expectedFrom } + : {}), + }; + correlations.register(op); + stateManager.addPendingOperation(op); + } + + // Apply the tool-result transform chain. Each transform's output is fed + // into the next; emitted blobs are persisted immediately so downstream + // transforms can rely on the spill being available. + let current = rawResult; + for (const transform of toolResultTransforms) { + const ctx = buildStrategyContext("tool-result-ingest"); + const tr = await transform.apply({ call, result: current }, ctx); + manifestBuffer.push(tr.record); + await persistBlobs(tr.blobs); + current = tr.output; + } + + return current; + }; + + let outcomes: (ToolResult | typeof SUSPENDED)[]; + if (parallel) { + const p = Promise.all(calls.map((c) => runOne(c))); + void track(p); + outcomes = await p; + } else { + outcomes = []; + for (const call of calls) { + const p = runOne(call); + void track(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); + + // Doom-loop accounting keys off the calls that actually ran, aligned to + // their outcome by index (both the parallel and serial paths above keep + // `outcomes` in `calls` order). A parked call contributes nothing, so a + // suspend-then-redispatch cycle counts its one real execution once. The + // loop reads `toolBatchRepeatCount` after this returns and breaks the run + // when it reaches the threshold. A `null` threshold means detection is + // disabled, so the accounting is skipped entirely. + const ranCalls = calls.filter((_call, i) => outcomes[i] !== SUSPENDED); + if (doomLoopThreshold !== null && ranCalls.length > 0) { + const signature = toolBatchSignature(ranCalls); + if (signature === lastToolBatchSignature) { + toolBatchRepeatCount += 1; + } else { + lastToolBatchSignature = signature; + toolBatchRepeatCount = 1; + } + lastToolBatchNames = ranCalls.map((call) => call.name); + } + + cycleToolCallsExecuted += results.length; + + if (addToHistory && stateManager !== null && results.length > 0) { + stateManager.appendTurn(createToolResultTurn(results)); + } + + for (const result of results) { + enqueue({ type: "tool.done", result }); + } + } + + async function executeCompact( + compactorName: string, + reason: string, + ): Promise { + if (stateManager === null) return; + const compactor = compactors[compactorName]; + if (compactor === undefined) { + throw new Error( + `executeCompact: no compactor registered for name ${JSON.stringify(compactorName)}`, + ); + } + + const ctx: StrategyContext = { + state: stateManager.snapshot(), + trigger: `director:${reason}`, + }; + const result = await compactor.apply(stateManager.getTurns(), ctx); + + stateManager.replaceTurns(result.output); + await contextStore.writeTurns(result.output); + await persistBlobs(result.blobs); + manifestBuffer.push(result.record); + cycleCompactorName = compactor.name; + + logger.info`Compaction by ${compactor.name} reduced history (reason: ${reason})`; + } + + // ------------------------------------------------------------------------- + // Cycle boundary commit + // ------------------------------------------------------------------------- + + function buildCycleMessage(): string { + if (pendingMessage !== null) { + const msg = pendingMessage; + pendingMessage = null; + return msg; + } + + if (cycleCompactorName !== null) { + return `Cycle: compaction by ${cycleCompactorName}`; + } + + const parts: string[] = []; + if (cycleInferred) parts.push("inferred"); + if (cycleToolCallsExecuted > 0) { + const noun = cycleToolCallsExecuted === 1 ? "tool call" : "tool calls"; + parts.push(`${String(cycleToolCallsExecuted)} ${noun}`); + } + + if (parts.length === 0) return "Cycle: no-op"; + return `Cycle: ${parts.join(" + ")}`; + } + + function resetCycleAccumulators(): void { + manifestBuffer = []; + cycleInferred = false; + cycleToolCallsExecuted = 0; + cycleCompactorName = null; + cycleSuspended = false; + } + + async function commitCycle(): Promise { + if (stateManager === null) return; + + // Only commit when the cycle did real work or the director set an + // override message. An empty cycle (no inference, no tools, no compact, + // no override) commits nothing. + const hasWork = + cycleInferred || + cycleToolCallsExecuted > 0 || + cycleCompactorName !== null || + cycleSuspended; + const hasOverride = pendingMessage !== null; + if (!hasWork && !hasOverride) { + resetCycleAccumulators(); + return; + } + + const message = buildCycleMessage(); + + try { + await contextStore.writeTurns(stateManager.getTurns()); + await contextStore.writeManifest(manifestBuffer); + await writeMetadata(); + const commit = await contextStore.commit({ message }); + lastCheckpointHash = commit.hash; + } catch (cause) { + logger.error`Cycle commit failed: ${cause}`; + emitError( + `Cycle commit failed: ${cause instanceof Error ? cause.message : String(cause)}`, + false, + ); + resetCycleAccumulators(); + return; + } + + resetCycleAccumulators(); + + if (afterCheckpoint !== undefined) { + try { + await afterCheckpoint(); + } catch (cause) { + logger.error`afterCheckpoint failed: ${cause}`; + emitError( + `afterCheckpoint failed: ${cause instanceof Error ? cause.message : String(cause)}`, + false, + ); + } + } + } + + async function writeMetadata(): Promise { + if (stateManager === null) return; + await contextStore.writeMetadata({ + pendingOperations: stateManager.getPendingOperations(), + tokenUsage: stateManager.getTokenUsage(), + }); + } + + // ------------------------------------------------------------------------- + // 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 + // ------------------------------------------------------------------------- + + async function loop(): Promise { + if (stateManager === null) { + throw new Error("State manager not initialized before loop"); + } + + while (!done) { + await waitForEvent(); + + if (done) break; + + const event = dequeueNext(); + if (event === undefined) continue; + + // A dequeued cycle event is one fewer in-flight continuation. Pairs with + // the increment in enqueue(); both key off CYCLE_EVENT_TYPES so they + // cannot drift. + if (CYCLE_EVENT_TYPES.has(event.type)) { + pendingContinuations -= 1; + } + + // Handle abort events: initiate shutdown regardless of director. + if (event.type === "abort") { + if (!shutdownStarted) { + done = true; + await initiateShutdown(); + } + break; + } + + // Append inbound messages to conversation history so the provider sees them. + // Each dequeued message.received opens a fresh per-message run bracket. + // If a prior bracket is still open (defensive — should not occur given + // the dequeue priority that drains cycle events before new messages), + // close it as completed first so the new bracket starts cleanly. + if (event.type === "message.received") { + if (stateManager !== null) { + const msg = createInboundTurn(event.message); + if (msg !== null) { + stateManager.appendTurn(msg); + } + } + if (currentMessageRunId !== null) { + closeMessageRun("completed"); + } + 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( + event, + stateManager.snapshot(), + capabilities, + ); + } catch (cause) { + const msg = cause instanceof Error ? cause.message : String(cause); + + logger.error`Director threw during decide: ${cause}`; + emitError(`Director exception: ${msg}`, true); + closeMessageRun("failed", { + message: `Director exception: ${msg}`, + kind: "reactor_fatal", + }); + done = true; + await initiateShutdown(); + break; + } + + const validation = validateActions(actions); + if (!validation.ok) { + emitError(`Invalid action set: ${validation.error}`, true); + closeMessageRun("failed", { + message: `Invalid action set: ${validation.error}`, + kind: "reactor_fatal", + }); + done = true; + await initiateShutdown(); + break; + } + + const normalized = validation.normalized; + + // Checkpoint sets the next cycle's commit message. + const checkpointAction = normalized.find( + (a): a is Extract => + a.type === "checkpoint", + ); + if (checkpointAction !== undefined) { + pendingMessage = checkpointAction.message; + } + + // Emit custom events (validated type namespace). + for (const action of normalized) { + if (action.type === "emit") { + const reserved = ["inference.", "tool.", "reactor.", "fork."]; + const blocked = reserved.some((p) => action.eventType.startsWith(p)); + if (blocked) { + emitError( + `Director tried to emit reserved event type: ${action.eventType}`, + false, + ); + continue; + } + emit({ type: action.eventType, seq: nextSeq(), data: action.data }); + } + } + + // Fork is excluded in this build. + for (const action of normalized) { + if (action.type === "fork") { + emitError("Fork action is not supported in this build", false); + } + } + + // Handle done. + if (normalized.some((a) => a.type === "done")) { + // Flush the cycle (in case the director paired done with checkpoint + // or other work) before shutting down. + await commitCycle(); + closeMessageRun("completed"); + done = true; + await initiateShutdown(); + break; + } + + // Handle wait: commit the cycle (if work happened) and return to the + // event loop without shutting down. Wait is a per-message terminal: + // the reactor has nothing more to do for the message and is returning + // to idle. + if (normalized.some((a) => a.type === "wait")) { + await commitCycle(); + closeMessageRun("completed"); + continue; + } + + // Handle suspend: register gate and continue the loop (don't block). + const suspendAction = normalized.find((a) => a.type === "suspend"); + if (suspendAction !== undefined && suspendAction.type === "suspend") { + const { gate } = suspendAction; + await suspendOnGate({ + gateType: gate.type, + gateId: gate.gateId, + timeoutMs: gate.timeoutMs > 0 ? gate.timeoutMs : gateTimeout, + correlationId: gate.correlationId, + pendingOp: undefined, + }); + continue; + } + + // Handle reply — emit the content for the harness/supervisor to send. + const replyAction = normalized.find((a) => a.type === "reply"); + if (replyAction !== undefined && replyAction.type === "reply") { + // Flush any pending cycle work before signaling the reply so the + // emitted checkpointHash matches the visible state. + await commitCycle(); + emit({ + type: "connector.reply", + seq: nextSeq(), + data: { + content: replyAction.content, + ...(lastCheckpointHash !== undefined + ? { checkpointHash: lastCheckpointHash } + : {}), + }, + }); + // Reply is a per-message terminal point: close the bracket so the + // next inbound message opens a fresh run. + closeMessageRun("completed"); + // After replying, wait for the next inbound message. + continue; + } + + // Handle compact (its own cycle; runs before any infer can be requested + // in the same director invocation — validation forbids that pairing). + const compactAction = normalized.find((a) => a.type === "compact"); + if (compactAction !== undefined && compactAction.type === "compact") { + try { + await executeCompact(compactAction.compactor, compactAction.reason); + } catch (cause) { + const msg = cause instanceof Error ? cause.message : String(cause); + logger.error`Compaction failed: ${cause}`; + emitError(`Compaction failed: ${msg}`, true); + closeMessageRun("failed", { + message: `Compaction failed: ${msg}`, + kind: "reactor_fatal", + }); + done = true; + await initiateShutdown(); + break; + } + await commitCycle(); + continue; + } + + // Handle infer. + const inferAction = normalized.find((a) => a.type === "infer"); + if (inferAction !== undefined && inferAction.type === "infer") { + await executeInfer(inferAction.options); + continue; + } + + // Handle execute_tools. + const toolsAction = normalized.find((a) => a.type === "execute_tools"); + if (toolsAction !== undefined && toolsAction.type === "execute_tools") { + const parallel = toolsAction.parallel !== false; + const addToHistory = toolsAction.addToHistory !== false; + await executeTools(toolsAction.calls, parallel, addToHistory); + if ( + doomLoopThreshold !== null && + toolBatchRepeatCount >= doomLoopThreshold + ) { + const tools = lastToolBatchNames.join(", "); + const message = + `Doom loop detected: an identical tool batch (${tools}) executed ` + + `${String(doomLoopThreshold)} times consecutively`; + emitError(message, true); + closeMessageRun("failed", { message, kind: "doom_loop" }); + done = true; + await initiateShutdown(); + break; + } + continue; + } + + // No infer/tools/reply/suspend/wait/compact action — if a checkpoint + // override was set on its own (or alongside emit/fork), the next event + // will pick it up. Nothing to flush here. + } + } + + function emitError(message: string, fatal: boolean): void { + emit({ + type: "reactor.error", + seq: nextSeq(), + data: { error: message, fatal }, + }); + } + + let lastCheckpointHash: string | undefined; + + async function initiateShutdown(): Promise { + if (shutdownStarted) return; + shutdownStarted = true; + + abortOperations(); + gates.shutdown(); + + if (stateManager !== null) { + stateManager.setGatesSnapshot([]); + } + + if (inFlight.size > 0) { + const deadline = new Promise((resolve) => + setTimeout(resolve, shutdownTimeoutMs), + ); + await Promise.race([Promise.allSettled([...inFlight]), deadline]); + } + + if (onShutdown !== undefined) { + try { + await onShutdown(); + } catch (cause) { + logger.error`onShutdown failed: ${cause}`; + emitError( + `onShutdown failed: ${cause instanceof Error ? cause.message : String(cause)}`, + false, + ); + } + } + + emit({ + type: "reactor.done", + seq: nextSeq(), + data: {}, + }); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + function start(): void { + if (running) { + throw new Error("Reactor is already running"); + } + running = true; + + void (async () => { + let initialTurns: ConversationTurn[]; + let initialOps; + let initialUsage: TokenUsage; + try { + const loaded = await contextStore.load(); + initialTurns = loaded.turns; + initialOps = loaded.pendingOperations; + initialUsage = loaded.tokenUsage; + } catch (cause) { + done = true; + startupDeliveries = null; + logger.error`Context store load failed: ${cause}`; + emitError( + `Context store load failed: ${cause instanceof Error ? cause.message : String(cause)}`, + true, + ); + emit({ type: "reactor.done", seq: nextSeq(), data: {} }); + return; + } + + stateManager = createStateManager( + sessionId, + initialTurns, + initialOps, + initialUsage, + ); + + 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: {} }); + + const bufferedDeliveries = startupDeliveries; + startupDeliveries = null; + if (bufferedDeliveries !== null) { + for (const message of bufferedDeliveries) { + processDelivery(message); + } + } + + await loop(); + } catch (cause) { + const msg = cause instanceof Error ? cause.message : String(cause); + done = true; + startupDeliveries = null; + logger.error`Reactor loop threw unexpectedly: ${cause}`; + emitError(`Internal reactor error: ${msg}`, true); + closeMessageRun("failed", { + message: `Internal reactor error: ${msg}`, + kind: "reactor_fatal", + }); + if (!shutdownStarted) { + await initiateShutdown(); + } + } + })(); + } + + function processDelivery(message: InboundMessage): void { + void (async () => { + let correlated: boolean; + try { + correlated = await tryCorrelate(message); + } catch (cause) { + // 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`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 }); + } + })(); + } + + function deliver(message: InboundMessage): void { + if (done) return; + if (startupDeliveries !== null) { + startupDeliveries.push(message); + return; + } + processDelivery(message); + } + + function abort(reason: AbortReason): void { + // The loop cannot dequeue the abort event while it is awaiting an active + // inference or tool batch. Signal that operation immediately so it can + // settle and return control to the loop, where the queued abort retains + // its priority over every other event. + operationController.abort(); + enqueue({ type: "abort", reason }); + } + + return { start, deliver, abort }; +} diff --git a/vendor/intx/inference/src/retry-policy.ts b/vendor/intx/inference/src/retry-policy.ts new file mode 100644 index 000000000..28467eb15 --- /dev/null +++ b/vendor/intx/inference/src/retry-policy.ts @@ -0,0 +1,99 @@ +// The default per-call mechanical retry policy. See `RetryPolicy` / +// `RetrySituation` / `RetryDecision` in `@intx/types/runtime` for the +// public contract. This module ships the opinionated defaults the +// harness substitutes when `InferenceOptions.retryPolicy` is omitted. +// +// The defaults are deliberately conservative: enough to absorb the +// transient-flake surface (TCP resets, 5xx, rate-limit jitter, half- +// streamed connection drops) without masking a genuinely persistent +// failure under a retry loop a human would never notice. + +import type { + RetryPolicy, + RetrySituation, + RetryDecision, +} from "@intx/types/runtime"; + +const MAX_ATTEMPTS = 3; +// Indexed by the failed attempt number (1-indexed): the delay BEFORE +// the attempt-after-this-one starts. Length must be `MAX_ATTEMPTS - 1` +// because after the final attempt fails the policy aborts. Drives the +// `retryable` and `timeout` schedules. +const RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS: readonly number[] = [500, 1000]; +const QUOTA_DEFAULT_DELAY_MS = 1000; + +/** + * The default retry policy bundled with `@intx/inference`. Behaviour by + * `InferenceError.category`: + * + * - `credential_failure`, `context_overflow`, `fatal`, `aborted`, + * `protocol_mismatch` — never retry. These categories describe a + * deterministic per-call failure that re-issuing the identical + * request cannot resolve: bad credentials stay bad, a too-large + * context stays too large, a caller-driven abort is intentional, + * and a wire-shape mismatch will repeat on the next response. + * - `retryable`, `timeout` — up to 3 attempts total. 500ms before + * attempt 2, then 1000ms before attempt 3. Exponential rather than + * constant so a server taking longer than usual to recover gets a + * slightly larger window each time without compounding into a long + * tail. + * - `quota_exhausted` — up to 3 attempts total. The delay is taken + * from `error.retryAfterMs` when the provider returned one (the + * server told us when it would be ready); otherwise a flat + * `1000`ms baseline. The baseline does NOT grow across attempts — + * if 1s isn't long enough for a rate limit to clear, exponential + * backoff on top of the provider's own pacing instructions is more + * likely to mask a config problem than help. Operators who need + * exponential pacing for rate limits should supply a custom policy. + * + * The 3-attempt cap is the same across every retryable category: a + * single transient flake is plausible, two is rare, and a third + * failure across the backoff schedule is a real signal that the call + * is not going to succeed on its own. + */ +export function createDefaultRetryPolicy(): RetryPolicy { + return (situation: RetrySituation): RetryDecision => { + const { error, attempt } = situation; + + switch (error.category) { + case "credential_failure": + case "context_overflow": + case "fatal": + case "aborted": + case "protocol_mismatch": + return { kind: "abort" }; + + case "retryable": + case "timeout": { + if (attempt >= MAX_ATTEMPTS) return { kind: "abort" }; + const delayMs = RETRYABLE_BACKOFF_BY_FAILED_ATTEMPT_MS[attempt - 1]; + if (delayMs === undefined) { + // Unreachable in practice given the `attempt >= MAX_ATTEMPTS` + // guard above, but the explicit narrowing keeps the schedule + // table and the cap from drifting silently if anyone bumps + // `MAX_ATTEMPTS` without extending the table. + return { kind: "abort" }; + } + return { kind: "retry", delayMs }; + } + + case "quota_exhausted": + if (attempt >= MAX_ATTEMPTS) return { kind: "abort" }; + return { + kind: "retry", + delayMs: error.retryAfterMs ?? QUOTA_DEFAULT_DELAY_MS, + }; + + default: { + // Exhaustiveness: if a new InferenceError.category lands + // without a clause here, the never-assignment fails at + // compile time rather than silently returning undefined + // from the policy callback. + const exhaustive: never = error.category; + throw new Error( + `createDefaultRetryPolicy: unhandled error category ${String(exhaustive)}`, + ); + } + } + }; +} diff --git a/vendor/intx/inference/src/sse.ts b/vendor/intx/inference/src/sse.ts new file mode 100644 index 000000000..0feeba22a --- /dev/null +++ b/vendor/intx/inference/src/sse.ts @@ -0,0 +1,76 @@ +// Server-Sent Events byte stream parser. +// +// Converts a ReadableStream (the raw HTTP response body) into an +// AsyncIterable of SSE data payloads. Each yielded string is the +// value of one `data:` field. Comments (`:`) and blank-line separators are +// consumed internally. The `[DONE]` sentinel (OpenAI convention) terminates +// the iteration. +// +// The parser buffers incomplete lines across chunk boundaries so split chunks +// are handled correctly regardless of where chunk boundaries fall. + +const decoder = new TextDecoder(); + +export async function* parseSSE( + stream: ReadableStream, +): AsyncIterable { + const reader = stream.getReader(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + // Flush any remaining content in the buffer as a final line. + if (buffer.length > 0) { + const payload = extractDataPayload(buffer); + if (payload !== null) { + yield payload; + } + } + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Process all complete lines (lines terminated by \n). + // A line ending in \r\n counts as terminated at the \n. + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf("\n")) !== -1) { + const rawLine = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + + // Strip trailing \r for CRLF line endings. + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + + // Blank lines and comment lines are ignored. + if (line === "" || line.startsWith(":")) { + continue; + } + + const payload = extractDataPayload(line); + if (payload === null) { + continue; + } + + if (payload === "[DONE]") { + return; + } + + yield payload; + } + } + } finally { + reader.releaseLock(); + } +} + +function extractDataPayload(line: string): string | null { + if (line.startsWith("data:")) { + // The spec allows an optional space after the colon. + const raw = line.slice(5); + return raw.startsWith(" ") ? raw.slice(1) : raw; + } + return null; +} diff --git a/vendor/intx/inference/src/state.ts b/vendor/intx/inference/src/state.ts new file mode 100644 index 000000000..867f81e1c --- /dev/null +++ b/vendor/intx/inference/src/state.ts @@ -0,0 +1,135 @@ +// Reactor state management: turn history, async operations, usage tracking. +// +// The state object is the authoritative view the director receives on every +// decision. It is mutable by the reactor only — the director receives a +// snapshot so it cannot corrupt the reactor's internal state. +// +// (INFERENCE.md § Agent Reactor › Director Decision Function) + +import type { + ConversationTurn, + LastCycleSource, + PendingOperation, + TokenUsage, + ReactorState, +} from "@intx/types/runtime"; +import type { GateSnapshot } from "./gates"; + +export type ReactorStateManager = ReturnType; + +/** + * Creates a mutable state container. All mutations go through explicit methods; + * the `snapshot()` method produces an immutable view for the director. + */ +export function createStateManager( + sessionId: string, + initialTurns: ConversationTurn[], + initialOps: PendingOperation[], + initialUsage: TokenUsage, +) { + let turns: ConversationTurn[] = [...initialTurns]; + const pendingOperations = new Map( + initialOps.map((op) => [op.correlationId, op]), + ); + const tokenUsage: TokenUsage = { ...initialUsage }; + let lastCycleUsage: TokenUsage | null = null; + let lastCycleSource: LastCycleSource | null = null; + let activeGatesSnapshot: GateSnapshot[] = []; + const activeForks: { forkId: string; mode: "independent" | "child" }[] = []; + + function appendTurn(msg: ConversationTurn): void { + turns.push(msg); + } + + function replaceTurns(next: ConversationTurn[]): void { + turns = [...next]; + } + + function addPendingOperation(op: PendingOperation): void { + pendingOperations.set(op.correlationId, op); + } + + function removePendingOperation(correlationId: string): void { + pendingOperations.delete(correlationId); + } + + function accumUsage(usage: TokenUsage): void { + tokenUsage.input += usage.input; + tokenUsage.output += usage.output; + tokenUsage.cacheRead += usage.cacheRead; + tokenUsage.cacheWrite += usage.cacheWrite; + tokenUsage.thinking += usage.thinking; + } + + function setLastCycleUsage(usage: TokenUsage): void { + lastCycleUsage = { ...usage }; + } + + function setLastCycleSource(source: LastCycleSource): void { + lastCycleSource = { ...source }; + } + + function setGatesSnapshot(gates: GateSnapshot[]): void { + activeGatesSnapshot = gates; + } + + function addFork(forkId: string, mode: "independent" | "child"): void { + activeForks.push({ forkId, mode }); + } + + function removeFork(forkId: string): void { + const idx = activeForks.findIndex((f) => f.forkId === forkId); + if (idx !== -1) activeForks.splice(idx, 1); + } + + function getTurns(): ConversationTurn[] { + return turns; + } + + function getPendingOperations(): PendingOperation[] { + return Array.from(pendingOperations.values()); + } + + function getTokenUsage(): TokenUsage { + return { ...tokenUsage }; + } + + function snapshot(): ReactorState { + return { + sessionId, + turns: turns.map((m) => ({ + ...m, + content: m.content.map((b) => structuredClone(b)), + })), + pendingOperations: Array.from(pendingOperations.values()).map((op) => + structuredClone(op), + ), + activeGates: activeGatesSnapshot.map((g) => ({ + gateId: g.gateId, + type: g.type, + timeoutAt: g.timeoutAt, + })), + activeForks: activeForks.map((f) => ({ ...f })), + tokenUsage: { ...tokenUsage }, + lastCycleUsage: lastCycleUsage !== null ? { ...lastCycleUsage } : null, + lastCycleSource: lastCycleSource !== null ? { ...lastCycleSource } : null, + }; + } + + return { + appendTurn, + replaceTurns, + addPendingOperation, + removePendingOperation, + accumUsage, + setLastCycleUsage, + setLastCycleSource, + setGatesSnapshot, + addFork, + removeFork, + getTurns, + getPendingOperations, + getTokenUsage, + snapshot, + }; +} diff --git a/vendor/intx/inference/src/tool-name.ts b/vendor/intx/inference/src/tool-name.ts new file mode 100644 index 000000000..1dbd4b782 --- /dev/null +++ b/vendor/intx/inference/src/tool-name.ts @@ -0,0 +1,128 @@ +// Invertible codec for tool names on the provider wire. +// +// Internal tool names are package-qualified ids like +// `@intx/tools-posix/sidecar-bundle:run_shell`. Provider function-name +// charsets are narrow (OpenAI `^[a-zA-Z0-9_-]{1,64}$`, Anthropic 128, Gemini +// with a leading-letter rule), and reject the `@`, `/`, `:`, and `.` +// characters these ids carry. This codec maps such a name to a +// wire-charset-safe form and back. +// +// Invariant: `decodeToolName(encodeToolName(x, ...)) === x`. It is +// load-bearing. Tool-call dispatch keys on the exact prefixed name in two +// places (the agent's `byName` map and the tool-package loader's per-bundle +// `nameMap`), so a name that does not round-trip lands as an `unknown tool` +// with no error at the point of the fault. The `encode`/`decode` naming +// advertises the invertibility on purpose: a `sanitize`-style name invites a +// future lossy "cleanup" that would break dispatch. +// +// Names that are already valid on the wire pass through untouched — the codec +// only rewrites names that genuinely need it. Rewritten names carry a +// distinctive `MARKER` prefix, and `decode` transforms only marker-prefixed +// names, so an ordinary wire-valid name a provider echoes (a tool the model +// named that never needed encoding, or a hallucination) is returned verbatim. +// The marker is what makes the round-trip unambiguous: a name that is already +// valid but happens to begin with the marker is force-encoded too, so a +// marker prefix on the wire always denotes an encoding. +// +// Rewriting escapes each out-of-charset character (and, so the sentinel stays +// unambiguous, each literal `-`) as `-XX`, its uppercase two-digit hex byte: +// `@`->`-40`, `/`->`-2F`, `:`->`-3A`, `.`->`-2E`, `-`->`-2D`. A `base64url` +// encoding (reusing `@intx/types/base64url`) was considered and rejected: it +// renders every name fully opaque, hurting both model tool-selection and +// debugging, and it would encode even the already-legible names this scheme +// leaves alone. + +// A distinctive, letter-leading prefix that ordinary tool names do not start +// with. Letter-leading satisfies providers (Gemini) that require a +// letter/underscore leading character on every function name. +const MARKER = "IX_"; + +// Characters that survive a rewrite verbatim. `-` is deliberately excluded so +// it can serve as the escape sentinel inside a rewritten name. +const ESCAPE_PASSTHROUGH = /^[A-Za-z0-9_]$/; +// The provider wire charset. A name already matching this, that starts with a +// letter or underscore and does not collide with the marker, needs no rewrite. +const WIRE_SAFE = /^[A-Za-z_][A-Za-z0-9_-]*$/; +const HEX_PAIR = /^[0-9A-Fa-f]{2}$/; + +// The wire-name length ceiling for a provider, with a label used in the loud +// error a too-long name raises. The limit is provider-specific, so the +// adapter that binds the provider owns the constant and passes it in. +export type ToolNameLimit = { + readonly provider: string; + readonly maxLength: number; +}; + +function needsRewrite(name: string): boolean { + return !WIRE_SAFE.test(name) || name.startsWith(MARKER); +} + +function rewrite(name: string): string { + let out = MARKER; + for (const ch of name) { + if (ESCAPE_PASSTHROUGH.test(ch)) { + out += ch; + continue; + } + const code = ch.charCodeAt(0); + if (code > 0xff) { + throw new Error( + `Cannot encode tool name "${name}": character "${ch}" is outside the ` + + `single-byte range the wire codec supports.`, + ); + } + out += "-" + code.toString(16).toUpperCase().padStart(2, "0"); + } + return out; +} + +// Encode a tool name into a form valid for the provider's function-name +// charset. Names already valid on the wire pass through unchanged. Throws if +// the resulting wire name exceeds the provider's length limit — on the +// passthrough path too, since an already-valid name can still be too long — so +// a name too long for a provider surfaces as a fixable diagnostic rather than +// truncation, a silent collision, or an opaque upstream 400. +export function encodeToolName(name: string, limit: ToolNameLimit): string { + const wire = needsRewrite(name) ? rewrite(name) : name; + if (wire.length > limit.maxLength) { + throw new Error( + `Tool name "${name}" is ${wire.length} chars on the wire, which ` + + `exceeds the ${limit.maxLength}-char limit for provider ` + + `"${limit.provider}". Shorten the tool bundle id or tool name.`, + ); + } + return wire; +} + +// Invert `encodeToolName`. Total: a wire name without the marker prefix, or a +// marker-prefixed name whose body is not a valid escaping, is returned +// unchanged. That covers both names that never needed encoding and +// hallucinated or provider-mangled names, which then fall through to the +// existing `unknown tool` handling — giving the model feedback to retry — +// rather than throwing and tearing down the stream over a bad tool name. +export function decodeToolName(wire: string): string { + if (!wire.startsWith(MARKER)) { + return wire; + } + const body = wire.slice(MARKER.length); + let out = ""; + let i = 0; + while (i < body.length) { + const ch = body.charAt(i); + if (ch === "-") { + const hex = body.slice(i + 1, i + 3); + if (!HEX_PAIR.test(hex)) { + return wire; + } + out += String.fromCharCode(parseInt(hex, 16)); + i += 3; + continue; + } + if (!ESCAPE_PASSTHROUGH.test(ch)) { + return wire; + } + out += ch; + i += 1; + } + return out; +} diff --git a/vendor/intx/inference/src/transform.ts b/vendor/intx/inference/src/transform.ts new file mode 100644 index 000000000..3b820413e --- /dev/null +++ b/vendor/intx/inference/src/transform.ts @@ -0,0 +1,177 @@ +// Cross-provider message transformation. +// +// When conversations cross provider boundaries the message history must be +// adapted: thinking blocks are stripped for foreign models, orphaned tool +// calls receive synthetic error results, and tool call IDs are normalized to +// a portable format. +// +// 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 { + formatSafetyRatingText, + type ConversationTurn, + type ContentBlock, +} from "@intx/types/runtime"; + +export type TransformOptions = { + targetModel: string; + // When true, keep thinking blocks for messages that originated from the + // same model. When false, strip all thinking blocks (cross-provider replay). + keepThinkingForSameModel?: boolean; +}; + +export function transformMessages( + messages: ConversationTurn[], + options: TransformOptions, +): ConversationTurn[] { + const { targetModel, keepThinkingForSameModel = true } = options; + + // First pass: strip thinking blocks and filter aborted assistant messages. + const filtered = messages + .map((msg): ConversationTurn | null => { + if (msg.role === "assistant") { + const isSameModel = msg.model === targetModel; + const keepThinking = keepThinkingForSameModel && isSameModel; + + 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). + const hasUsableContent = filteredContent.some( + (b) => b.type === "text" || b.type === "tool_call", + ); + if (!hasUsableContent && filteredContent.length === 0) { + return null; + } + + return { ...msg, content: filteredContent }; + } + return msg; + }) + .filter((msg): msg is ConversationTurn => msg !== null); + + // Second pass: inject synthetic tool results for orphaned tool calls. + return injectOrphanedToolResults(filtered); +} + +function injectOrphanedToolResults( + messages: ConversationTurn[], +): ConversationTurn[] { + const result: ConversationTurn[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (msg === undefined) continue; + result.push(msg); + + if (msg.role !== "assistant") continue; + + const toolCalls = msg.content.filter( + (b): b is Extract => + b.type === "tool_call", + ); + + if (toolCalls.length === 0) continue; + + // Collect tool call IDs from this assistant message. + const calledIds = new Set(toolCalls.map((tc) => tc.id)); + + // Check the following messages for results that cover these calls. + const coveredIds = new Set(); + for (let j = i + 1; j < messages.length; j++) { + const next = messages[j]; + if (next === undefined) break; + if (next.role !== "user") break; + + for (const block of next.content) { + if (block.type === "tool_result") { + coveredIds.add(block.callId); + } + } + } + + // Find which tool calls have no corresponding result. + const orphanedIds = [...calledIds].filter((id) => !coveredIds.has(id)); + + if (orphanedIds.length === 0) continue; + + // Inject a synthetic user message with error tool results for each orphan. + const syntheticBlocks: ContentBlock[] = orphanedIds.map((id) => ({ + type: "tool_result" as const, + callId: id, + content: [ + { + type: "text" as const, + text: "Tool execution was interrupted before completion.", + }, + ], + isError: true, + })); + + result.push({ + role: "user", + content: syntheticBlocks, + timestamp: Date.now(), + }); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Tool call ID normalization +// +// OpenAI Responses API generates 450+ character IDs with pipes. Anthropic +// has strict format requirements. IDs are normalized to a short portable +// format with a bidirectional map for round-trip fidelity. +// --------------------------------------------------------------------------- + +const PORTABLE_ID_PREFIX = "tc_"; + +export type IDNormalizer = { + normalize(providerId: string): string; + resolve(portableId: string): string | undefined; +}; + +export function createIDNormalizer(): IDNormalizer { + const portableToProvider = new Map(); + const providerToPortable = new Map(); + let counter = 0; + + return { + normalize(providerId: string): string { + const existing = providerToPortable.get(providerId); + if (existing !== undefined) return existing; + + const portable = `${PORTABLE_ID_PREFIX}${(++counter).toString(36)}`; + providerToPortable.set(providerId, portable); + portableToProvider.set(portable, providerId); + return portable; + }, + + resolve(portableId: string): string | undefined { + return portableToProvider.get(portableId); + }, + }; +} diff --git a/vendor/intx/inference/src/transforms/index.ts b/vendor/intx/inference/src/transforms/index.ts new file mode 100644 index 000000000..fdcb43eed --- /dev/null +++ b/vendor/intx/inference/src/transforms/index.ts @@ -0,0 +1,2 @@ +export { createSizeCapTransform } from "./size-cap"; +export type { SizeCapTransformOptions } from "./size-cap"; diff --git a/vendor/intx/inference/src/transforms/size-cap.ts b/vendor/intx/inference/src/transforms/size-cap.ts new file mode 100644 index 000000000..65145eb13 --- /dev/null +++ b/vendor/intx/inference/src/transforms/size-cap.ts @@ -0,0 +1,110 @@ +// Tool-result size-cap transform. +// +// When a tool result's content exceeds `maxChars`, the full bytes are spilled +// via `contextStore.writeBlob` and the inline result is replaced with the +// first `maxChars` characters plus a marker pointing at the spill via a +// `tool-output:///{callId}` URI. The agent's `read_file` tool resolves the +// URI through its `BlobReader` capability. +// +// Within-cap results pass through unchanged (no blob is written) but still +// produce a `TransformRecord` so the manifest captures every invocation. + +import type { + ContextStore, + StrategyContext, + StrategyResult, + ToolResult, + ToolResultTransform, +} from "@intx/types/runtime"; + +const SIZE_CAP_VERSION = "1"; +const SIZE_CAP_NAME = "size-cap"; + +export type SizeCapTransformOptions = { + maxChars: number; + contextStore: Pick; +}; + +/** + * Create a `ToolResultTransform` that caps inline tool result content at + * `maxChars` characters. Oversized results are spilled to the context store + * via `writeBlob` and the inline content becomes a truncated marker + * referencing the spill by `tool-output:///{callId}` URI. + */ +export function createSizeCapTransform( + options: SizeCapTransformOptions, +): ToolResultTransform { + const { maxChars, contextStore } = options; + if (!Number.isFinite(maxChars) || maxChars <= 0) { + throw new Error( + `createSizeCapTransform: maxChars must be a positive finite number, got ${String(maxChars)}`, + ); + } + + return { + name: SIZE_CAP_NAME, + version: SIZE_CAP_VERSION, + async apply( + input: { call: { id: string; name: string }; result: ToolResult }, + _ctx: StrategyContext, + ): Promise> { + const { call, result } = input; + const text = + typeof result.content === "string" + ? result.content + : JSON.stringify(result.content); + + if (text.length <= maxChars) { + return { + output: result, + record: { + strategy: SIZE_CAP_NAME, + version: SIZE_CAP_VERSION, + parameters: { maxChars }, + reason: "within-cap", + decisions: { callId: call.id, length: text.length }, + }, + }; + } + + const omitted = text.length - maxChars; + const kept = text.slice(0, maxChars); + const spillURI = `tool-output:///${call.id}`; + const marker = + `${kept}\n[Tool output truncated: omitted ${String(omitted)} chars. ` + + `Full output available at ${spillURI} -- use read_file with that URI to see the rest.]`; + + const bytes = new TextEncoder().encode(text); + await contextStore.writeBlob(call.id, bytes, "text/plain"); + + const output: ToolResult = { + ...result, + content: marker, + }; + + return { + output, + record: { + strategy: SIZE_CAP_NAME, + version: SIZE_CAP_VERSION, + parameters: { maxChars }, + reason: "exceeded-cap", + decisions: { + callId: call.id, + originalLength: text.length, + kept: maxChars, + spillKey: call.id, + spillURI, + }, + }, + blobs: [ + { + key: call.id, + bytes, + contentType: "text/plain", + }, + ], + }; + }, + }; +} diff --git a/vendor/intx/inference/src/turns.ts b/vendor/intx/inference/src/turns.ts new file mode 100644 index 000000000..e0a08a62c --- /dev/null +++ b/vendor/intx/inference/src/turns.ts @@ -0,0 +1,166 @@ +import type { + ConversationTurn, + ContentBlock, + AssistantTurn, + InboundMessage, + MediaSource, + MessageAttachment, + ToolCall, + ToolResult, +} from "@intx/types/runtime"; +import { attachmentCategory, base64Encode } from "@intx/types"; +import { getLogger } from "@intx/log"; + +const logger = getLogger(["interchange", "inference", "turns"]); + +export type { ConversationTurn, ContentBlock, AssistantTurn }; + +export type { ToolCall, ToolResult }; + +/** + * Map a received attachment to a model ContentBlock. + * + * The dispatch uses two policies that look unified but are not, and must + * stay distinct: + * - major-type dispatch for image/*, video/*, audio/* → the matching block; + * - allowlist-category dispatch for the document category + * (application/pdf, application/json, text/plain, text/csv, + * text/markdown) → DocumentBlock. + * Do NOT collapse this into "always dispatch by major type": that would + * route text/plain to a text block instead of DocumentBlock. + * + * This function is total — it never throws. The hub route allowlist only + * guards the local user-upload path; inbound attachments arrive from remote + * senders via fetchFull and are not subtype-filtered here (provider adapters + * are the contract layer that rejects unsupported media at marshal time, per + * INTERCHANGE message design). Any major-type media is therefore passed + * through to the adapter. A type that maps to no block at all (e.g. an + * archive) degrades to a visible text marker rather than throwing: throwing + * here would propagate into the reactor's ungoverned delivery path and let a + * single malformed remote attachment tear down the session. + */ +function attachmentToContentBlock(att: MessageAttachment): ContentBlock { + const majorType = att.contentType.split("/")[0]; + if ( + majorType === "image" || + majorType === "video" || + majorType === "audio" || + attachmentCategory(att.contentType) === "document" + ) { + const source: MediaSource = { + kind: "base64", + mimeType: att.contentType, + data: base64Encode(att.data), + }; + if (majorType === "image") return { type: "image", source }; + if (majorType === "video") return { type: "video", source }; + if (majorType === "audio") return { type: "audio", source }; + return { type: "document", source }; + } + + logger.warn`Unsupported attachment content type ${att.contentType}; surfacing as a text marker`; + return { + type: "text", + text: `[Unsupported attachment: ${att.name} (${att.contentType})]`, + }; +} + +export function createInboundTurn( + message: InboundMessage, +): ConversationTurn | null { + const content = message.content ?? ""; + const attachments = message.attachments ?? []; + if (content.length === 0 && attachments.length === 0) return null; + + const blocks: ContentBlock[] = []; + + if (content.length > 0) { + const { from, subject } = message.headers; + const envelope: string[] = []; + if (from.length > 0) envelope.push(`[From: ${from}]`); + if (subject !== undefined && subject.length > 0) { + envelope.push(`[Subject: ${subject}]`); + } + const text = + envelope.length > 0 ? `${envelope.join("\n")}\n\n${content}` : content; + blocks.push({ type: "text", text }); + } + + for (const att of attachments) { + blocks.push(attachmentToContentBlock(att)); + } + + return { + role: "user", + content: blocks, + timestamp: Date.now(), + }; +} + +/** + * Assert that a prompt's tool_call / tool_result blocks are structurally + * well-formed before it is sent to a provider. + * + * Throws if a tool_call id is emitted twice, if a tool_result references a + * callId with no preceding tool_call, or if two tool_result blocks answer the + * same callId. None of these are valid in a coherent tool conversation — a + * tool call has exactly one result. Catching it here surfaces the corruption + * as an internal error at the assembly boundary, with the offending id and + * turn index, instead of an opaque downstream provider rejection. + * + * This deliberately does NOT require every tool_call to have a result: an + * unanswered tool_call is left legitimately by an after-inference halt/abort + * and is repaired downstream for cross-provider replay. + */ +export function assertWellFormedToolSequence(turns: ConversationTurn[]): void { + const calledIds = new Set(); + const answeredIds = new Set(); + + for (let turnIndex = 0; turnIndex < turns.length; turnIndex++) { + const turn = turns[turnIndex]; + if (turn === undefined) continue; + + for (const block of turn.content) { + if (block.type === "tool_call") { + if (calledIds.has(block.id)) { + throw new Error( + `Malformed tool sequence: duplicate tool_call id ${JSON.stringify(block.id)} at turn ${String(turnIndex)}`, + ); + } + calledIds.add(block.id); + } else if (block.type === "tool_result") { + if (!calledIds.has(block.callId)) { + throw new Error( + `Malformed tool sequence: tool_result for ${JSON.stringify(block.callId)} at turn ${String(turnIndex)} has no preceding tool_call`, + ); + } + if (answeredIds.has(block.callId)) { + throw new Error( + `Malformed tool sequence: duplicate tool_result for ${JSON.stringify(block.callId)} at turn ${String(turnIndex)}`, + ); + } + answeredIds.add(block.callId); + } + } + } +} + +export function createToolResultTurn(results: ToolResult[]): ConversationTurn { + const blocks: ContentBlock[] = results.map((r) => { + const raw = + typeof r.content === "string" ? r.content : JSON.stringify(r.content); + const block: Extract = { + type: "tool_result", + callId: r.callId, + content: [{ type: "text" as const, text: raw }], + }; + if (r.detail !== undefined) { + block.detail = r.detail; + } + if (r.isError !== undefined) { + block.isError = r.isError; + } + return block; + }); + return { role: "user", content: blocks, timestamp: Date.now() }; +} diff --git a/vendor/intx/inference/tsconfig.json b/vendor/intx/inference/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/inference/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/mail-memory/README.md b/vendor/intx/mail-memory/README.md new file mode 100644 index 000000000..e98a3ab0b --- /dev/null +++ b/vendor/intx/mail-memory/README.md @@ -0,0 +1,37 @@ +# @intx/mail-memory + +In-memory `MessageTransport` for single-process and test +environments. A single transport instance hosts every address in +the process; each registered address gets its own per-recipient +transport handle that signs outbound mail with the provided +`CryptoProvider` and delivers inbound mail straight to the +recipient's INBOX. + +Consumed by examples, tests, and the in-process `@intx/agent` +runtime. The on-the-wire format matches `@intx/mime` so a fixture +captured here can be replayed against a real transport without +modification. + +```ts +import { createInMemoryTransport } from "@intx/mail-memory"; +import { createEd25519Crypto, generateKeyPair } from "@intx/crypto"; + +const transport = createInMemoryTransport(); +const alpha = createEd25519Crypto(await generateKeyPair()); +const beta = createEd25519Crypto(await generateKeyPair()); + +transport.register("alpha@local.interchange", alpha); +transport.register("beta@local.interchange", beta); + +const alphaMail = transport.getTransportFor("alpha@local.interchange"); +await alphaMail.send({ + to: "beta@local.interchange", + type: "conversation.message", + content: "hello", +}); +``` + +Install a `RemoteSendHandler` via `transport.setRemoteSendHandler` +to forward outbound mail for addresses the transport does not host +locally, and add one or more `MessageSentHandler`s via +`transport.addMessageSentHandler` for post-send observability. diff --git a/vendor/intx/mail-memory/VENDORED-FROM b/vendor/intx/mail-memory/VENDORED-FROM new file mode 100644 index 000000000..9c2a127ee --- /dev/null +++ b/vendor/intx/mail-memory/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/mail-memory) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. diff --git a/vendor/intx/mail-memory/package.json b/vendor/intx/mail-memory/package.json new file mode 100644 index 000000000..3f928472d --- /dev/null +++ b/vendor/intx/mail-memory/package.json @@ -0,0 +1,37 @@ +{ + "name": "@intx/mail-memory", + "description": "In-memory MessageTransport for single-process and test environments", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/log": "0.3.0", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/mail-memory/src/index.ts b/vendor/intx/mail-memory/src/index.ts new file mode 100644 index 000000000..b20ec7b8b --- /dev/null +++ b/vendor/intx/mail-memory/src/index.ts @@ -0,0 +1,25 @@ +export { InMemoryTransport, type HubTransport } from "./transport"; +export type { + RemoteSendHandler, + MessageSentHandler, + MessageSentContext, +} from "./send"; + +/** + * Create a fresh in-memory transport instance. + * + * The returned transport is shared across all addresses in a single + * process. Register addresses before sending messages: + * + * const transport = createInMemoryTransport(); + * transport.register("alpha@local.interchange", cryptoProviderA); + * transport.register("beta@local.interchange", cryptoProviderB); + * + * const alphaTransport = transport.getTransportFor("alpha@local.interchange"); + * await alphaTransport.send({ to: "beta@local.interchange", ... }); + */ +import { InMemoryTransport } from "./transport"; + +export function createInMemoryTransport(): InMemoryTransport { + return new InMemoryTransport(); +} diff --git a/vendor/intx/mail-memory/src/mailbox.ts b/vendor/intx/mail-memory/src/mailbox.ts new file mode 100644 index 000000000..e04150215 --- /dev/null +++ b/vendor/intx/mail-memory/src/mailbox.ts @@ -0,0 +1,29 @@ +import type { CryptoProvider, MailboxEvent } from "@intx/types/runtime"; +import { + DEFAULT_MAILBOXES, + createInMemoryMailboxStore, + type MailboxStore, +} from "@intx/mailbox"; + +/** + * Per-address state for the in-memory transport: one `MailboxStore` per + * mailbox, the watch callbacks registered against each mailbox, and the + * address's `CryptoProvider`. + */ +export type AddressEntry = { + mailboxes: Map; + watchCallbacks: Map void>>; + crypto: CryptoProvider; +}; + +export function createAddressEntry(crypto: CryptoProvider): AddressEntry { + const mailboxes = new Map(); + for (const name of DEFAULT_MAILBOXES) { + mailboxes.set(name, createInMemoryMailboxStore()); + } + return { + mailboxes, + watchCallbacks: new Map(), + crypto, + }; +} diff --git a/vendor/intx/mail-memory/src/send.ts b/vendor/intx/mail-memory/src/send.ts new file mode 100644 index 000000000..d3c927095 --- /dev/null +++ b/vendor/intx/mail-memory/src/send.ts @@ -0,0 +1,309 @@ +import type { + OutboundMessage, + SendReceipt, + MailboxEvent, +} from "@intx/types/runtime"; +import { buildMessageHeaders, type StoredEnvelope } from "@intx/mailbox"; +import { + assembleSignedContent, + assembleMessage, + generateMessageId, + isMessageId, + parseHeaderSection, + createDetachedSignatureFromProvider, + type MessageHeaders as MimeMessageHeaders, + type ConversationContent, + type StructuredContent, +} from "@intx/mime"; +import type { AddressEntry } from "./mailbox"; + +const CONVERSATION_TYPES = new Set([ + "conversation.message", + "conversation.join", + "conversation.leave", +]); + +/** + * Callback for delivering messages to recipients not registered on this + * transport. The federation layer provides this to forward messages to + * the hub for remote routing. + */ +export type RemoteSendHandler = ( + rawMessage: Uint8Array, + recipients: string[], +) => Promise; + +/** + * Context passed to MessageSentHandler callbacks after a message is fully + * assembled and delivered. + */ +export type MessageSentContext = { + senderAddress: string; + rawMessage: Uint8Array; + messageId: string; + /** Deduplicated union of to and cc — the full routing set. */ + recipients: string[]; + /** To addresses only (before merging with cc). */ + to: string[]; + /** CC addresses only. Empty array when no CC recipients. */ + cc: string[]; + /** True when all recipients were delivered locally (no remote leg). */ + localOnly: boolean; +}; + +/** + * Callback fired after a message is fully assembled and delivered. The + * send is already complete when this fires — a handler rejection does + * not mean the message was not delivered. + * + * Used by the sidecar to commit outbound wire messages to the git audit + * trail and forward metadata to the hub. + */ +export type MessageSentHandler = (ctx: MessageSentContext) => Promise; + +/** + * Execute the send() flow: + * 1. Validate sender registration, split recipients into local/remote + * 2. Build signed content part (MIME bytes to sign) + * 3. Sign with sender's CryptoProvider + * 4. Assemble the complete RFC 2822 message + * 5. Append to each local recipient's INBOX and sender's Sent mailbox + * 6. Forward to remote recipients via onRemoteSend + * 7. Schedule watch callbacks asynchronously via queueMicrotask + * 8. Fire onMessageSent callback (fire-and-forget) + * + * If onRemoteSend is not provided and there are remote recipients, send() + * throws. If onRemoteSend rejects, the error propagates — local delivery + * that already completed is not rolled back. This is a known limitation: + * partial delivery is possible when a message has both local and remote + * recipients and the remote leg fails. + */ +export async function executeSend( + senderAddress: string, + message: OutboundMessage, + entries: Map, + onRemoteSend?: RemoteSendHandler, + onMessageSent?: MessageSentHandler, +): Promise { + const senderEntry = entries.get(senderAddress); + if (senderEntry === undefined) { + throw new Error( + `Sender "${senderAddress}" is not registered with this transport`, + ); + } + const senderCrypto = senderEntry.crypto; + + const recipients = Array.isArray(message.to) ? message.to : [message.to]; + if (recipients.length === 0) { + throw new Error("OutboundMessage must have at least one recipient"); + } + + const ccAddressList = + message.cc !== undefined + ? Array.isArray(message.cc) + ? message.cc + : [message.cc] + : []; + + const allAddressees = [...new Set([...recipients, ...ccAddressList])]; + const remoteRecipients = allAddressees.filter((addr) => !entries.has(addr)); + + if (remoteRecipients.length > 0 && onRemoteSend === undefined) { + throw new Error( + `Recipient "${remoteRecipients[0]}" is not registered with this transport`, + ); + } + + const isConversation = CONVERSATION_TYPES.has(message.type); + + if (isConversation && message.payload !== undefined) { + throw new Error( + "Conversation messages must not carry a structured payload", + ); + } + if (!isConversation && message.content !== undefined) { + throw new Error("Structured messages must not carry a text content field"); + } + + const messageId = generateMessageId(senderAddress); + const now = new Date(); + + let content: ConversationContent | StructuredContent; + if (isConversation) { + content = { + kind: "conversation", + text: message.content ?? "", + }; + } else { + const payload = message.payload ?? {}; + const envelope = { + type: message.type, + version: "1", + body: payload, + }; + const structured: StructuredContent = { + kind: "structured", + json: envelope, + }; + if (message.summary !== undefined) structured.summary = message.summary; + content = structured; + } + + const signedContentBytes = assembleSignedContent(content); + const signatureBytes = await createDetachedSignatureFromProvider( + signedContentBytes, + senderCrypto, + ); + + const ccAddresses = ccAddressList.length > 0 ? ccAddressList : undefined; + + const refs = buildReferences(message.inReplyTo, message.references); + + const mimeHeaders: MimeMessageHeaders = { + from: senderAddress, + to: recipients, + cc: ccAddresses, + date: now, + messageId, + subject: message.subject, + inReplyTo: message.inReplyTo, + references: refs, + mimeVersion: "1.0", + interchangeType: message.type, + interchangeCorrelationId: message.correlationId, + interchangeTenantId: message.tenantId, + interchangeAgentId: undefined, + interchangeSessionId: message.sessionId, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + }; + + const rawBytes = assembleMessage( + mimeHeaders, + signedContentBytes, + signatureBytes, + ); + const envelope: StoredEnvelope = { + messageId, + from: senderAddress, + to: recipients, + subject: message.subject ?? "", + date: now, + inReplyTo: message.inReplyTo, + references: refs ?? [], + interchangeType: message.type, + interchangeCorrelationId: message.correlationId, + }; + + // Deliver to each local recipient's INBOX. + const deliveredUids: { address: string; uid: number }[] = []; + for (const recipient of allAddressees) { + const entry = entries.get(recipient); + if (entry === undefined) continue; + const inbox = entry.mailboxes.get("INBOX"); + if (inbox === undefined) { + throw new Error( + `Mailbox "INBOX" does not exist for recipient "${recipient}"`, + ); + } + const uid = inbox.append(rawBytes, envelope, []); + deliveredUids.push({ address: recipient, uid }); + } + + // Append copy to sender's Sent mailbox. + const sentStore = senderEntry.mailboxes.get("Sent"); + if (sentStore === undefined) { + throw new Error( + `Mailbox "Sent" does not exist for sender "${senderAddress}"`, + ); + } + sentStore.append(rawBytes, envelope, ["\\Seen"]); + + // Fire local recipient watch callbacks ASYNCHRONOUSLY (per MESSAGE.md + // requirement). queueMicrotask ensures callbacks never run synchronously + // on the sender's call stack, preserving real IMAP IDLE async delivery + // semantics. Scheduled before the remote send so local delivery + // notifications are not delayed by network latency. + const { headers: parsedHeaders } = parseHeaderSection(rawBytes); + const msgHeaders = buildMessageHeaders(parsedHeaders); + + for (const { address, uid } of deliveredUids) { + const entry = entries.get(address); + if (entry === undefined) { + throw new Error( + `Entry for "${address}" disappeared between delivery and callback dispatch`, + ); + } + const callbacks = entry.watchCallbacks.get("INBOX"); + if (callbacks === undefined || callbacks.size === 0) continue; + + const event: MailboxEvent = { + type: "exists", + uid, + headers: msgHeaders, + }; + + for (const cb of callbacks) { + queueMicrotask(() => cb(event)); + } + } + + // Forward to remote recipients via federation hook. + if (remoteRecipients.length > 0 && onRemoteSend !== undefined) { + await onRemoteSend(rawBytes, remoteRecipients); + } + + if (onMessageSent !== undefined) { + const localOnly = remoteRecipients.length === 0; + onMessageSent({ + senderAddress, + rawMessage: rawBytes, + messageId, + recipients: allAddressees, + to: recipients, + cc: ccAddressList, + localOnly, + }).catch((err: unknown) => { + queueMicrotask(() => { + throw err instanceof Error + ? err + : new Error(`MessageSentHandler failed: ${String(err)}`); + }); + }); + } + + return { + messageId, + status: remoteRecipients.length > 0 ? "queued" : "delivered", + }; +} + +/** + * Assemble the wire `References` chain for an outbound message. + * + * When the caller supplies a full ancestry (`existingReferences` -- the + * parent's References plus the parent's Message-Id, built by the threaded + * reply path), that chain is used and `inReplyTo` is appended only when it is + * not already the tail. Otherwise the chain is derived from `inReplyTo` alone, + * preserving the pre-existing single-element behavior. + * + * The caller-supplied chain is filtered to RFC 2822 message identifiers: + * inbound mail can carry a headerless-derived (sha256) or otherwise malformed + * Message-Id that is a valid claim-check key but not a valid `` + * identifier, and such a value must not leak into a `References` header. The + * `inReplyTo` value is appended without filtering, matching the pre-existing + * `In-Reply-To`/`References` behavior for a bare reply. + */ +function buildReferences( + inReplyTo: string | undefined, + existingReferences: string[] | undefined, +): string[] | undefined { + const refs = (existingReferences ?? []).filter(isMessageId); + if (inReplyTo === undefined) return refs.length > 0 ? refs : undefined; + if (!refs.includes(inReplyTo)) { + return [...refs, inReplyTo]; + } + return refs; +} diff --git a/vendor/intx/mail-memory/src/transport.ts b/vendor/intx/mail-memory/src/transport.ts new file mode 100644 index 000000000..d920b2a0e --- /dev/null +++ b/vendor/intx/mail-memory/src/transport.ts @@ -0,0 +1,786 @@ +import type { + MessageTransport, + OutboundMessage, + SendReceipt, + InboundMessage, + MessageRef, + Mailbox, + MailboxStatus, + SearchQuery, + Thread, + MessageHeaders, + BodyStructure, + MessagePart, + SyncState, + SyncResult, + ListInfo, + MailboxEvent, + Unsubscribe, + CryptoProvider, +} from "@intx/types/runtime"; +import { parseHeaderSection } from "@intx/mime"; +import { + buildMessageHeaders, + createInMemoryMailboxStore, + executeSearch, + executeThread, + fetchHeaders as doFetchHeaders, + fetchStructure as doFetchStructure, + fetchPart as doFetchPart, + fetchFull as doFetchFull, + requireMessage, + type StoredEnvelope, +} from "@intx/mailbox"; +import { createAddressEntry, type AddressEntry } from "./mailbox"; +import { + executeSend, + type RemoteSendHandler, + type MessageSentHandler, +} from "./send"; + +/** + * The hub-side surface a transport must expose to coordinate per-agent + * registration, mail routing, and outbound-audit hooks. SessionManager + * and HubLink in `@intx/hub-agent` depend on this interface rather + * than on `InMemoryTransport` directly so custom hosts can supply + * their own backend (e.g. an SMTP/IMAP relay) without touching the + * package's seams. + */ +export interface HubTransport { + register(address: string, crypto: CryptoProvider): void; + unregister(address: string): void; + getTransportFor(address: string): MessageTransport; + setRemoteSendHandler(handler: RemoteSendHandler): void; + addMessageSentHandler(handler: MessageSentHandler): void; + /** + * Drop a hub-routed RFC 2822 message directly into an address's + * inbox. Used by the wire layer (HubLink) for inbound mail frames. + */ + deliver(address: string, message: Uint8Array): void; +} + +/** + * In-memory MessageTransport implementing full IMAP semantics within a + * single process. Messages are stored as real RFC 2822 MIME byte buffers. + * + * Every outbound message is PGP/MIME signed with the sender's CryptoProvider. + * Signature verification runs on fetchFull(). + * + * Addresses must be registered before sending or receiving messages. + */ +export class InMemoryTransport implements MessageTransport, HubTransport { + readonly #entries = new Map(); + #remoteSendHandler: RemoteSendHandler | undefined; + readonly #messageSentHandlers = new Set(); + + /** + * Set a handler for delivering messages to recipients not registered on + * this transport. The federation layer calls this to wire up the websocket + * connection to the hub. When set, send() forwards unregistered recipients + * to this handler instead of throwing. + */ + setRemoteSendHandler(handler: RemoteSendHandler): void { + this.#remoteSendHandler = handler; + } + + /** + * Register a handler that fires after every successful send(). Multiple + * handlers may be registered. The message is already delivered when + * handlers fire — a handler rejection does not mean the message was not + * delivered. + */ + addMessageSentHandler(handler: MessageSentHandler): void { + this.#messageSentHandlers.add(handler); + } + + /** + * Register an address with its CryptoProvider. Creates the default set + * of mailboxes (INBOX, Sent, Drafts, Archive, Trash). + * + * Throws if the address is already registered. + */ + register(address: string, crypto: CryptoProvider): void { + if (this.#entries.has(address)) { + throw new Error(`Address "${address}" is already registered`); + } + this.#entries.set(address, createAddressEntry(crypto)); + } + + /** + * Remove an address's mailboxes and crypto provider. Called when a + * session is destroyed so the address can be re-registered later. + */ + unregister(address: string): void { + this.#entries.delete(address); + } + + // --------------------------------------------------------------------------- + // Outbound + // --------------------------------------------------------------------------- + + async send( + _message: OutboundMessage, + _signal?: AbortSignal, + ): Promise { + throw new Error( + "Use createInMemoryTransport().getTransportFor(address) to send messages", + ); + } + + async append( + _mailbox: string, + _message: InboundMessage, + _flags?: string[], + _signal?: AbortSignal, + ): Promise { + throw new Error( + "Use createInMemoryTransport().getTransportFor(address) to append messages", + ); + } + + // --------------------------------------------------------------------------- + // Mailbox management (per-address — use getTransportFor) + // --------------------------------------------------------------------------- + + async listMailboxes(_signal?: AbortSignal): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async createMailbox(_name: string, _signal?: AbortSignal): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async deleteMailbox(_name: string, _signal?: AbortSignal): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async getMailboxStatus( + _name: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async search( + _mailbox: string, + _query: SearchQuery, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async thread( + _mailbox: string, + _algorithm: "references" | "orderedsubject", + _query?: SearchQuery, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async fetchHeaders( + _ref: MessageRef, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async fetchStructure( + _ref: MessageRef, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async fetchPart( + _ref: MessageRef, + _partPath: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async fetchFull( + _ref: MessageRef, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async setFlags( + _ref: MessageRef, + _flags: string[], + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async clearFlags( + _ref: MessageRef, + _flags: string[], + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async move( + _ref: MessageRef, + _toMailbox: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async copy( + _ref: MessageRef, + _toMailbox: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async expunge( + _mailbox: string, + _signal?: AbortSignal, + ): Promise<{ expungedUids: number[] }> { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + watch( + _mailbox: string, + _callback: (event: MailboxEvent) => void, + ): Unsubscribe { + throw new Error("Use getTransportFor(address) for per-address operations"); + } + + async sync( + _mailbox: string, + _knownState: SyncState, + _signal?: AbortSignal, + ): Promise { + throw new Error("sync() (QRESYNC) is not implemented"); + } + + async createList( + _address: string, + _name: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + async listMembers( + _address: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + async subscribe( + _listAddress: string, + _subscriberAddress: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + async unsubscribe( + _listAddress: string, + _subscriberAddress: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + // --------------------------------------------------------------------------- + // Inbound delivery from federation + // --------------------------------------------------------------------------- + + /** + * Deliver a signed MIME message to an address's INBOX. Used by the + * federation layer when a message arrives from the hub over the + * websocket — the message is already assembled and signed by the + * originating sender, so no further processing is needed beyond + * envelope parsing and storage. + * + * Throws if the address is not registered. + */ + deliver(address: string, message: Uint8Array): void { + const entry = this.#entries.get(address); + if (entry === undefined) { + throw new Error( + `Address "${address}" is not registered — cannot deliver mail`, + ); + } + const inbox = entry.mailboxes.get("INBOX"); + if (inbox === undefined) { + throw new Error(`Address "${address}" has no INBOX`); + } + + const { headers } = parseHeaderSection(message); + + const messageId = headers.get("message-id"); + const from = headers.get("from"); + const dateRaw = headers.get("date"); + if (messageId === undefined) { + throw new Error("Cannot deliver message: missing Message-ID header"); + } + if (from === undefined) { + throw new Error("Cannot deliver message: missing From header"); + } + if (dateRaw === undefined) { + throw new Error("Cannot deliver message: missing Date header"); + } + + const msgHeaders = buildMessageHeaders(headers); + + const toRaw = headers.get("to") ?? ""; + const to = toRaw + ? toRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const refsRaw = headers.get("references"); + const references = refsRaw ? refsRaw.split(/\s+/).filter(Boolean) : []; + + const envelope: StoredEnvelope = { + messageId, + from, + to, + subject: headers.get("subject") ?? "", + date: new Date(dateRaw), + inReplyTo: headers.get("in-reply-to"), + references, + interchangeType: headers.get("interchange-type"), + interchangeCorrelationId: headers.get("interchange-correlation-id"), + }; + + const uid = inbox.append(message, envelope, []); + + const callbacks = entry.watchCallbacks.get("INBOX"); + if (callbacks !== undefined && callbacks.size > 0) { + const event: import("@intx/types/runtime").MailboxEvent = { + type: "exists", + uid, + headers: msgHeaders, + }; + for (const cb of callbacks) { + queueMicrotask(() => cb(event)); + } + } + } + + // --------------------------------------------------------------------------- + // Internal: per-address view + // --------------------------------------------------------------------------- + + /** + * Returns a MessageTransport scoped to the given address. Callers use + * this to send and read mail as that address. + */ + getTransportFor(address: string): MessageTransport { + if (!this.#entries.has(address)) { + throw new Error( + `Address "${address}" is not registered — call register() first`, + ); + } + return new ScopedMessageTransport( + address, + this.#entries, + () => this.#remoteSendHandler, + () => this.#messageSentHandlers, + ); + } +} + +/** + * MessageTransport scoped to a single address. All operations target that + * address's mailboxes. Constructed via InMemoryTransport.getTransportFor(). + */ +class ScopedMessageTransport implements MessageTransport { + readonly #address: string; + readonly #entries: Map; + readonly #getRemoteSendHandler: () => RemoteSendHandler | undefined; + readonly #getMessageSentHandlers: () => Set; + + constructor( + address: string, + entries: Map, + getRemoteSendHandler: () => RemoteSendHandler | undefined, + getMessageSentHandlers: () => Set, + ) { + this.#address = address; + this.#entries = entries; + this.#getRemoteSendHandler = getRemoteSendHandler; + this.#getMessageSentHandlers = getMessageSentHandlers; + } + + get #entry(): AddressEntry { + const e = this.#entries.get(this.#address); + if (e === undefined) { + throw new Error(`Address "${this.#address}" has been deregistered`); + } + return e; + } + + #requireMailbox(name: string) { + const store = this.#entry.mailboxes.get(name); + if (store === undefined) { + throw new Error( + `Mailbox "${name}" does not exist for address "${this.#address}"`, + ); + } + return store; + } + + async send( + message: OutboundMessage, + _signal?: AbortSignal, + ): Promise { + // Trip the deregistered guard so callers using a stale scoped handle + // see a precise error rather than the generic "sender is not + // registered" thrown by executeSend. + void this.#entry; + + const handlers = this.#getMessageSentHandlers(); + const aggregatedHandler: MessageSentHandler | undefined = + handlers.size > 0 + ? async (ctx) => { + await Promise.allSettled([...handlers].map((h) => h(ctx))); + } + : undefined; + return executeSend( + this.#address, + message, + this.#entries, + this.#getRemoteSendHandler(), + aggregatedHandler, + ); + } + + async append( + mailbox: string, + message: InboundMessage, + flags?: string[], + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(mailbox); + // For append, we need to convert InboundMessage back to raw bytes. + // Since InboundMessage may come from a prior fetchFull, we need the raw + // bytes. This is a design gap — append() takes InboundMessage but we + // need Uint8Array. We store a minimal representation. + // + // For now, serialize the InboundMessage as a minimal RFC 2822 message. + const raw = inboundMessageToRaw(message); + const envelope = { + messageId: message.headers.messageId, + from: message.headers.from, + to: message.headers.to, + subject: message.headers.subject ?? "", + date: new Date(message.headers.date), + inReplyTo: message.headers.inReplyTo, + references: message.headers.references ?? [], + interchangeType: message.headers.interchangeType, + interchangeCorrelationId: message.headers.interchangeCorrelationId, + }; + const uid = store.append(raw, envelope, flags ?? []); + return { uid, mailbox }; + } + + async listMailboxes(_signal?: AbortSignal): Promise { + return Array.from(this.#entry.mailboxes.keys()).map((name) => ({ + name, + })); + } + + async createMailbox(name: string, _signal?: AbortSignal): Promise { + if (this.#entry.mailboxes.has(name)) { + throw new Error( + `Mailbox "${name}" already exists for address "${this.#address}"`, + ); + } + this.#entry.mailboxes.set(name, createInMemoryMailboxStore()); + return { name }; + } + + async deleteMailbox(name: string, _signal?: AbortSignal): Promise { + if (!this.#entry.mailboxes.has(name)) { + throw new Error( + `Mailbox "${name}" does not exist for address "${this.#address}"`, + ); + } + this.#entry.mailboxes.delete(name); + } + + async getMailboxStatus( + name: string, + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(name); + const unseen = store.messages.filter((m) => !m.flags.has("\\Seen")).length; + return { + total: store.messages.length, + unseen, + recent: 0, + uidNext: store.uidNext, + uidValidity: store.uidValidity, + highestModSeq: store.highestModSeq, + }; + } + + async search( + mailbox: string, + query: SearchQuery, + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(mailbox); + return await executeSearch(mailbox, store, query); + } + + async thread( + mailbox: string, + algorithm: "references" | "orderedsubject", + query?: SearchQuery, + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(mailbox); + return await executeThread(mailbox, store, algorithm, query); + } + + async fetchHeaders( + ref: MessageRef, + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(ref.mailbox); + return await doFetchHeaders(ref, store); + } + + async fetchStructure( + ref: MessageRef, + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(ref.mailbox); + return await doFetchStructure(ref, store); + } + + async fetchPart( + ref: MessageRef, + partPath: string, + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(ref.mailbox); + return await doFetchPart(ref, partPath, store); + } + + async fetchFull( + ref: MessageRef, + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(ref.mailbox); + return await doFetchFull( + ref, + store, + (addr) => this.#entries.get(addr)?.crypto, + ); + } + + async setFlags( + ref: MessageRef, + flags: string[], + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(ref.mailbox); + const msg = store.addFlags(ref.uid, flags); + this.#fireWatchCallbacks(ref.mailbox, { + type: "flagsChanged", + uid: ref.uid, + flags: Array.from(msg.flags), + }); + } + + async clearFlags( + ref: MessageRef, + flags: string[], + _signal?: AbortSignal, + ): Promise { + const store = this.#requireMailbox(ref.mailbox); + const msg = store.removeFlags(ref.uid, flags); + this.#fireWatchCallbacks(ref.mailbox, { + type: "flagsChanged", + uid: ref.uid, + flags: Array.from(msg.flags), + }); + } + + async move( + ref: MessageRef, + toMailbox: string, + _signal?: AbortSignal, + ): Promise { + const fromStore = this.#requireMailbox(ref.mailbox); + const toStore = this.#requireMailbox(toMailbox); + const msg = requireMessage(fromStore, ref.uid, ref.mailbox); + const raw = await fromStore.readRaw(ref.uid); + fromStore.remove(ref.uid); + + const newUid = toStore.append(raw, msg.envelope, Array.from(msg.flags)); + + this.#fireWatchCallbacks(ref.mailbox, { + type: "expunged", + uid: ref.uid, + }); + + // Notify watchers of the new message in the destination mailbox. + const { headers: parsedHeaders } = parseHeaderSection(raw); + const msgHeaders = this.#buildMessageHeaders(parsedHeaders); + this.#fireWatchCallbacks(toMailbox, { + type: "exists", + uid: newUid, + headers: msgHeaders, + }); + } + + async copy( + ref: MessageRef, + toMailbox: string, + _signal?: AbortSignal, + ): Promise { + const fromStore = this.#requireMailbox(ref.mailbox); + const toStore = this.#requireMailbox(toMailbox); + const msg = requireMessage(fromStore, ref.uid, ref.mailbox); + const raw = await fromStore.readRaw(ref.uid); + + const newUid = toStore.append(raw, msg.envelope, Array.from(msg.flags)); + + const { headers: parsedHeaders } = parseHeaderSection(raw); + const msgHeaders = this.#buildMessageHeaders(parsedHeaders); + this.#fireWatchCallbacks(toMailbox, { + type: "exists", + uid: newUid, + headers: msgHeaders, + }); + } + + async expunge( + mailbox: string, + _signal?: AbortSignal, + ): Promise<{ expungedUids: number[] }> { + const store = this.#requireMailbox(mailbox); + const toExpunge = store.messages.filter((m) => m.flags.has("\\Deleted")); + + for (const msg of toExpunge) { + store.remove(msg.uid); + } + + for (const msg of toExpunge) { + this.#fireWatchCallbacks(mailbox, { + type: "expunged", + uid: msg.uid, + }); + } + + return { expungedUids: toExpunge.map((m) => m.uid) }; + } + + watch(mailbox: string, callback: (event: MailboxEvent) => void): Unsubscribe { + this.#requireMailbox(mailbox); + let callbacks = this.#entry.watchCallbacks.get(mailbox); + if (callbacks === undefined) { + callbacks = new Set(); + this.#entry.watchCallbacks.set(mailbox, callbacks); + } + callbacks.add(callback); + + return () => { + const cbs = this.#entry.watchCallbacks.get(mailbox); + cbs?.delete(callback); + }; + } + + async sync( + _mailbox: string, + _knownState: SyncState, + _signal?: AbortSignal, + ): Promise { + throw new Error("sync() (QRESYNC) is not implemented"); + } + + async createList( + _address: string, + _name: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + async listMembers( + _address: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + async subscribe( + _listAddress: string, + _subscriberAddress: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + async unsubscribe( + _listAddress: string, + _subscriberAddress: string, + _signal?: AbortSignal, + ): Promise { + throw new Error("Distribution list management is not implemented"); + } + + #fireWatchCallbacks(mailbox: string, event: MailboxEvent): void { + const callbacks = this.#entry.watchCallbacks.get(mailbox); + if (callbacks === undefined || callbacks.size === 0) return; + for (const cb of callbacks) { + queueMicrotask(() => cb(event)); + } + } + + #buildMessageHeaders( + headers: Map, + ): import("@intx/types/runtime").MessageHeaders { + return buildMessageHeaders(headers); + } +} + +function inboundMessageToRaw(message: InboundMessage): Uint8Array { + const enc = new TextEncoder(); + const CRLF = "\r\n"; + let headers = ""; + headers += `From: ${message.headers.from}${CRLF}`; + headers += `To: ${message.headers.to.join(", ")}${CRLF}`; + if (message.headers.cc && message.headers.cc.length > 0) { + headers += `Cc: ${message.headers.cc.join(", ")}${CRLF}`; + } + headers += `Date: ${message.headers.date}${CRLF}`; + headers += `Message-ID: ${message.headers.messageId}${CRLF}`; + if (message.headers.subject !== undefined) { + headers += `Subject: ${message.headers.subject}${CRLF}`; + } + if (message.headers.inReplyTo !== undefined) { + headers += `In-Reply-To: ${message.headers.inReplyTo}${CRLF}`; + } + if (message.headers.references && message.headers.references.length > 0) { + headers += `References: ${message.headers.references.join(" ")}${CRLF}`; + } + if (message.headers.interchangeType !== undefined) { + headers += `Interchange-Type: ${message.headers.interchangeType}${CRLF}`; + } + + const body = + message.content ?? + (message.payload !== undefined ? JSON.stringify(message.payload) : ""); + headers += `Content-Type: text/plain${CRLF}`; + headers += `${CRLF}`; + return enc.encode(headers + body); +} diff --git a/vendor/intx/mail-memory/tsconfig.json b/vendor/intx/mail-memory/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/mail-memory/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/mailbox/README.md b/vendor/intx/mailbox/README.md new file mode 100644 index 000000000..9a6a103e6 --- /dev/null +++ b/vendor/intx/mailbox/README.md @@ -0,0 +1,30 @@ +# @intx/mailbox + +Storage-agnostic IMAP mailbox model. A `MailboxStore` backing owns how a +mailbox's message list and its uid/modseq/uidValidity counters are stored; the +pure query and projection functions read the message snapshot the backing +exposes. + +The package ships one reference backing, `createInMemoryMailboxStore`, which +keeps messages and counters in process memory. Other backings (for example a +persistent substrate) implement the same `MailboxStore` interface, so the +search, threading, and fetch logic is written once and reused across every +backing. + +```ts +import { + createInMemoryMailboxStore, + executeSearch, + fetchFull, +} from "@intx/mailbox"; + +const store = createInMemoryMailboxStore(); +const uid = store.append(rawMessageBytes, envelope, []); + +const hits = executeSearch("INBOX", store, { from: "alpha@local.interchange" }); +const message = await fetchFull({ uid, mailbox: "INBOX" }, store, getCrypto); +``` + +The projections parse from the stored RFC 2822 bytes, so +`@intx/mailbox` produces the same envelope, structure, and signature results +regardless of which backing holds the message. diff --git a/vendor/intx/mailbox/VENDORED-FROM b/vendor/intx/mailbox/VENDORED-FROM new file mode 100644 index 000000000..90fb2408d --- /dev/null +++ b/vendor/intx/mailbox/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/mailbox) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. diff --git a/vendor/intx/mailbox/package.json b/vendor/intx/mailbox/package.json new file mode 100644 index 000000000..e56df1094 --- /dev/null +++ b/vendor/intx/mailbox/package.json @@ -0,0 +1,35 @@ +{ + "name": "@intx/mailbox", + "description": "Storage-agnostic IMAP mailbox model with search, threading, and fetch projections", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/mime": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/mailbox/src/fetch.ts b/vendor/intx/mailbox/src/fetch.ts new file mode 100644 index 000000000..792c7d6e9 --- /dev/null +++ b/vendor/intx/mailbox/src/fetch.ts @@ -0,0 +1,250 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME multipart parsing with bounds checks */ +import { type } from "arktype"; +import type { + MessageHeaders, + BodyStructure, + MessagePart, + InboundMessage, + SignatureStatus, + CryptoProvider, + MessageRef, +} from "@intx/types/runtime"; +import { InterchangeType } from "@intx/types/runtime"; +import { base64Decode } from "@intx/types"; +import type { MailboxStore } from "./mailbox"; +import { requireMessage } from "./mailbox"; +import { + parseHeaderSection, + parseMimePart, + extractBoundary, + parseMultipart, + extractPartByPath, + extractAttachments, +} from "@intx/mime"; +import { buildMessageHeaders } from "./headers"; +import { verifyDetachedSignature } from "@intx/crypto"; + +const MessagePayload = type({ + type: InterchangeType, + version: "string", + body: "Record", +}); + +/** + * Parse the full RFC 2822 headers of a stored message. Reads the message's raw + * bytes on demand: the parsed set is a superset of the pre-parsed envelope (it + * carries `cc`, `mimeVersion`, trace headers, ...), so it cannot be served from + * the envelope metadata alone. + */ +export async function fetchHeaders( + ref: MessageRef, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers } = parseHeaderSection(raw); + return buildMessageHeaders(headers); +} + +/** + * Compute the MIME tree structure (BODYSTRUCTURE) without transferring content. + */ +export async function fetchStructure( + ref: MessageRef, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? "application/octet-stream"; + return buildStructure(body, contentType); +} + +/** + * Fetch a single MIME part by dot-separated path. + */ +export async function fetchPart( + ref: MessageRef, + partPath: string, + store: MailboxStore, +): Promise { + requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const partBytes = extractPartByPath(raw, partPath); + const part = parseMimePart(partBytes); + + const enc = part.headers.get("content-transfer-encoding") ?? "7bit"; + let content: Uint8Array; + + if (enc.toLowerCase() === "base64") { + const b64 = new TextDecoder().decode(part.body).replace(/\s/g, ""); + content = base64Decode(b64); + } else { + content = part.body; + } + + const result: MessagePart = { + contentType: part.contentType, + content, + }; + if (enc !== "7bit") result.encoding = enc; + return result; +} + +/** + * Fetch a complete message, verify its PGP/MIME signature, and return + * a fully parsed InboundMessage. + */ +export async function fetchFull( + ref: MessageRef, + store: MailboxStore, + getCrypto: (fromAddress: string) => CryptoProvider | undefined, +): Promise { + const msg = requireMessage(store, ref.uid, ref.mailbox); + const raw = await store.readRaw(ref.uid); + const { headers } = parseHeaderSection(raw); + const parsedHeaders = buildMessageHeaders(headers); + + const rawType = parsedHeaders.interchangeType; + const isConversation = + rawType === "conversation.message" || + rawType === "conversation.join" || + rawType === "conversation.leave" || + rawType === undefined; + + const signatureStatus = await verifyMessageSignature( + raw, + parsedHeaders.from, + getCrypto, + ); + + const result: InboundMessage = { + ref, + headers: parsedHeaders, + flags: Array.from(msg.flags), + signatureStatus, + }; + + try { + if (isConversation) { + const part1 = parseMimePart(extractPartByPath(raw, "1")); + const part1Mime = part1.contentType.split(";")[0]!.trim().toLowerCase(); + if (part1Mime.startsWith("multipart/")) { + // Conversation shape: multipart/mixed with the text body at 1.1. + const textPart = parseMimePart(extractPartByPath(raw, "1.1")); + result.content = new TextDecoder("utf-8", { fatal: false }).decode( + textPart.body, + ); + } else { + // A conversation message is "literally a signed email", so a sender + // (e.g. a plain mail client) may sign a bare text/plain part with no + // multipart/mixed wrapper. This branch reads that body directly. Our + // own assembler always emits multipart/mixed; without this branch a + // bare text/plain message would fail the 1.1 lookup and silently lose + // its content to the catch below. + result.content = new TextDecoder("utf-8", { fatal: false }).decode( + part1.body, + ); + } + } else { + // Structured messages carry their JSON payload at 1.1. Attachments on + // structured messages are intentionally not parsed: they have no + // producer today, so parsing them would handle a shape nobody sends. + const part11Bytes = extractPartByPath(raw, "1.1"); + const part11 = parseMimePart(part11Bytes); + const jsonText = new TextDecoder("utf-8", { fatal: false }).decode( + part11.body, + ); + const validated = MessagePayload(JSON.parse(jsonText)); + if (validated instanceof type.errors) { + throw new Error(`invalid message payload: ${validated.summary}`); + } + result.payload = validated; + } + } catch { + // If we can't parse the content, return what we have with the signature status. + } + + // Attachment parsing is deliberately outside the catch above: a malformed + // attachment must surface as a thrown error, not be silently dropped. + if (isConversation) { + const attachments = extractAttachments(raw); + if (attachments.length > 0) { + result.attachments = attachments; + } + } + + return result; +} + +async function verifyMessageSignature( + raw: Uint8Array, + fromAddress: string, + getCrypto: (fromAddress: string) => CryptoProvider | undefined, +): Promise { + const senderCrypto = getCrypto(fromAddress); + if (senderCrypto === undefined) { + return "unknown"; + } + + try { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? ""; + + if (!contentType.toLowerCase().includes("multipart/signed")) { + return "missing"; + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) return "missing"; + + const parts = parseMultipart(body, boundary); + if (parts.length < 2) return "missing"; + + const signedContentBytes = parts[0]!; + const sigPartBytes = parts[1]!; + const sigPart = parseMimePart(sigPartBytes); + + if ( + !sigPart.contentType.toLowerCase().includes("application/pgp-signature") + ) { + return "missing"; + } + + const publicKey = senderCrypto.getPublicKey(); + const valid = await verifyDetachedSignature( + signedContentBytes, + sigPart.body, + publicKey, + ); + + return valid ? "valid" : "invalid"; + } catch { + return "invalid"; + } +} + +function buildStructure(body: Uint8Array, contentType: string): BodyStructure { + const ct = contentType.toLowerCase(); + if (!ct.startsWith("multipart/")) { + return { contentType, size: body.length }; + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) { + return { contentType, size: body.length }; + } + + const parts = parseMultipart(body, boundary); + const subStructures: BodyStructure[] = parts.map((partBytes) => { + const { headers, bodyOffset } = parseHeaderSection(partBytes); + const partBody = partBytes.slice(bodyOffset); + const partContentType = + headers.get("content-type") ?? "application/octet-stream"; + return buildStructure(partBody, partContentType); + }); + + return { contentType, parts: subStructures }; +} diff --git a/vendor/intx/mailbox/src/headers.ts b/vendor/intx/mailbox/src/headers.ts new file mode 100644 index 000000000..0e4021126 --- /dev/null +++ b/vendor/intx/mailbox/src/headers.ts @@ -0,0 +1,5 @@ +// `buildMessageHeaders` now lives in `@intx/mime` alongside the rest of the +// MIME/header parsing (it is also what the `decodeMail` decoder builds its +// typed header subset with). Re-exported here so mail-memory's callers keep +// their existing import path. +export { buildMessageHeaders } from "@intx/mime"; diff --git a/vendor/intx/mailbox/src/index.ts b/vendor/intx/mailbox/src/index.ts new file mode 100644 index 000000000..efc103b74 --- /dev/null +++ b/vendor/intx/mailbox/src/index.ts @@ -0,0 +1,11 @@ +export { + DEFAULT_MAILBOXES, + createInMemoryMailboxStore, + requireMessage, +} from "./mailbox"; +export type { MailboxStore, StoredMessage, StoredEnvelope } from "./mailbox"; + +export { executeSearch } from "./search"; +export { executeThread } from "./thread"; +export { fetchHeaders, fetchStructure, fetchPart, fetchFull } from "./fetch"; +export { buildMessageHeaders } from "./headers"; diff --git a/vendor/intx/mailbox/src/mailbox.ts b/vendor/intx/mailbox/src/mailbox.ts new file mode 100644 index 000000000..05a3e9455 --- /dev/null +++ b/vendor/intx/mailbox/src/mailbox.ts @@ -0,0 +1,192 @@ +/** + * Pre-parsed envelope extracted from MIME headers at delivery time. + * Avoids re-parsing raw bytes for every search operation. + */ +export type StoredEnvelope = { + messageId: string; + from: string; + to: string[]; + subject: string; + date: Date; + inReplyTo: string | undefined; + references: string[]; + interchangeType: string | undefined; + interchangeCorrelationId: string | undefined; +}; + +/** + * A single stored message's resident model: its uid, the IMAP counters, its + * flags, and the pre-parsed envelope. The complete RFC 2822 bytes are NOT + * resident here; they are read on demand through `MailboxStore.readRaw`, so a + * backing can bound its in-memory footprint to metadata and keep the raw bytes + * on disk (the substrate backing) or retain them itself (the in-memory + * backing). The projections that need the bytes -- `fetchFull`, `fetchPart`, + * `fetchStructure`, `fetchHeaders`, and the raw-scanning search predicates -- + * route through `readRaw`, which returns the verbatim bytes so signature + * verification stays byte-exact. + */ +export type StoredMessage = { + uid: number; + modseq: number; + flags: Set; + envelope: StoredEnvelope; +}; + +/** + * Storage-agnostic per-mailbox model. A backing owns how the message list and + * the uid/modseq/uidValidity counters are stored; the pure query and + * projection functions (search, thread, fetch, bodystructure, headers) read + * the message snapshot the backing exposes through `messages`, and read a + * message's raw bytes on demand through `readRaw`. + * + * The counters follow IMAP semantics: `uidNext` is the UID that the next + * `append` will assign (UIDNEXT), `highestModSeq` is the largest MODSEQ + * currently assigned (HIGHESTMODSEQ), and `uidValidity` is stable for the + * lifetime of the mailbox (UIDVALIDITY). + */ +export interface MailboxStore { + readonly uidValidity: number; + readonly uidNext: number; + readonly highestModSeq: number; + readonly messages: readonly StoredMessage[]; + + /** + * Store a message, assigning it the next UID and MODSEQ. Returns the + * assigned UID. The backing decides whether to retain `raw` in memory or + * persist it and serve it from disk through `readRaw`. + */ + append(raw: Uint8Array, envelope: StoredEnvelope, flags: string[]): number; + + /** + * Read a stored message's verbatim RFC 2822 bytes. Resolves the bytes from + * wherever the backing keeps them (memory or disk). Throws if no message has + * the given UID. + */ + readRaw(uid: number): Promise; + + /** Locate a stored message by UID, or `undefined` if none matches. */ + find(uid: number): StoredMessage | undefined; + + /** + * Add flags to a stored message and advance its MODSEQ. Returns the updated + * message. Throws if no message has the given UID. + */ + addFlags(uid: number, flags: string[]): StoredMessage; + + /** + * Remove flags from a stored message and advance its MODSEQ. Returns the + * updated message. Throws if no message has the given UID. + */ + removeFlags(uid: number, flags: string[]): StoredMessage; + + /** Drop a stored message by UID. Throws if no message has the given UID. */ + remove(uid: number): void; +} + +/** + * The default set of mailboxes created for a freshly registered address. + */ +export const DEFAULT_MAILBOXES = [ + "INBOX", + "Sent", + "Drafts", + "Archive", + "Trash", +] as const; + +/** + * Create an in-memory `MailboxStore` backing. Messages, counters, and + * uidValidity live in process memory for the lifetime of the returned store. + */ +export function createInMemoryMailboxStore(): MailboxStore { + const messages: StoredMessage[] = []; + // The in-memory backing is its own durable store, so it legitimately retains + // every message's raw bytes. `readRaw` returns them; the metadata mirror in + // `messages` stays free of the bytes so the read model matches the + // disk-backed backing. + const rawByUid = new Map(); + let uidCounter = 1; + let modseqCounter = 1; + const uidValidity = Date.now(); + + function find(uid: number): StoredMessage | undefined { + return messages.find((m) => m.uid === uid); + } + + function require(uid: number): StoredMessage { + const msg = find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${uid} not found`); + } + return msg; + } + + return { + uidValidity, + get uidNext() { + return uidCounter; + }, + get highestModSeq() { + return modseqCounter - 1; + }, + get messages() { + return messages; + }, + append(raw, envelope, flags) { + const uid = uidCounter++; + const modseq = modseqCounter++; + messages.push({ uid, modseq, flags: new Set(flags), envelope }); + rawByUid.set(uid, raw); + return uid; + }, + readRaw(uid) { + const raw = rawByUid.get(uid); + if (raw === undefined) { + return Promise.reject(new Error(`Message UID ${uid} not found`)); + } + return Promise.resolve(raw); + }, + find, + addFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.add(flag); + } + msg.modseq = modseqCounter++; + return msg; + }, + removeFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.delete(flag); + } + msg.modseq = modseqCounter++; + return msg; + }, + remove(uid) { + const idx = messages.findIndex((m) => m.uid === uid); + if (idx === -1) { + throw new Error(`Message UID ${uid} not found`); + } + messages.splice(idx, 1); + rawByUid.delete(uid); + }, + }; +} + +/** + * Locate a stored message by UID, throwing a mailbox-qualified error when it + * is absent. Used by the fetch projections, which resolve a `MessageRef` + * against a specific mailbox. + */ +export function requireMessage( + store: MailboxStore, + uid: number, + mailboxName: string, +): StoredMessage { + const msg = store.find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${uid} not found in mailbox "${mailboxName}"`); + } + return msg; +} diff --git a/vendor/intx/mailbox/src/search.ts b/vendor/intx/mailbox/src/search.ts new file mode 100644 index 000000000..4efc54c2e --- /dev/null +++ b/vendor/intx/mailbox/src/search.ts @@ -0,0 +1,208 @@ +import type { SearchQuery, MessageRef } from "@intx/types/runtime"; +import type { MailboxStore, StoredMessage } from "./mailbox"; +import { parseHeaderSection } from "@intx/mime"; + +/** + * Execute an IMAP SEARCH-equivalent query over a mailbox. + * + * Supports: from, to, cc, bcc, header (field match), before/after/on, + * sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text, + * largerThan, smallerThan, and boolean and/or/not composition. + * + * The envelope- and flag-based predicates (from, to, dates, flags, boolean + * composition) resolve from metadata alone. The predicates that inspect + * headers the envelope does not carry (cc, bcc, arbitrary `header`), the body, + * or the raw size (body, text, largerThan, smallerThan) read a message's raw + * bytes on demand through `store.readRaw`, memoized per message so a query that + * touches raw reads each candidate's blob at most once. A query with no + * raw-scanning predicate never reads a blob. + * + * Returns MessageRef[] for all matching messages, ordered by UID. + */ +export async function executeSearch( + mailboxName: string, + store: MailboxStore, + query: SearchQuery, +): Promise { + const results: MessageRef[] = []; + for (const msg of store.messages) { + if (await matchMessage(msg, query, makeRawReader(store, msg.uid))) { + results.push({ uid: msg.uid, mailbox: mailboxName }); + } + } + return results; +} + +/** + * A per-message memoized reader for the raw bytes. The first raw-scanning + * predicate reads the blob through `store.readRaw`; every later predicate on + * the same message reuses the resolved bytes. + */ +function makeRawReader( + store: MailboxStore, + uid: number, +): () => Promise { + let pending: Promise | undefined; + return () => { + if (pending === undefined) pending = store.readRaw(uid); + return pending; + }; +} + +async function matchMessage( + msg: StoredMessage, + query: SearchQuery, + readRaw: () => Promise, +): Promise { + if (query.from !== undefined) { + if (!msg.envelope.from.toLowerCase().includes(query.from.toLowerCase())) { + return false; + } + } + + if (query.to !== undefined) { + const queryTo = query.to; + const toMatch = msg.envelope.to.some((addr) => + addr.toLowerCase().includes(queryTo.toLowerCase()), + ); + if (!toMatch) return false; + } + + if (query.cc !== undefined) { + const headers = await lazyHeaders(msg, readRaw); + const ccHeader = headers.get("cc") ?? ""; + if (!ccHeader.toLowerCase().includes(query.cc.toLowerCase())) { + return false; + } + } + + if (query.bcc !== undefined) { + const headers = await lazyHeaders(msg, readRaw); + const bccHeader = headers.get("bcc") ?? ""; + if (!bccHeader.toLowerCase().includes(query.bcc.toLowerCase())) { + return false; + } + } + + if (query.header !== undefined) { + const { field, contains } = query.header; + const headers = await lazyHeaders(msg, readRaw); + const value = headers.get(field.toLowerCase()) ?? ""; + if (!value.toLowerCase().includes(contains.toLowerCase())) { + return false; + } + } + + if (query.before !== undefined) { + if (msg.envelope.date >= query.before) return false; + } + if (query.after !== undefined) { + if (msg.envelope.date <= query.after) return false; + } + if (query.on !== undefined) { + const d = msg.envelope.date; + const q = query.on; + if ( + d.getUTCFullYear() !== q.getUTCFullYear() || + d.getUTCMonth() !== q.getUTCMonth() || + d.getUTCDate() !== q.getUTCDate() + ) { + return false; + } + } + + // Sent date filters use the Date header (same as envelope date here). + if (query.sentBefore !== undefined) { + if (msg.envelope.date >= query.sentBefore) return false; + } + if (query.sentAfter !== undefined) { + if (msg.envelope.date <= query.sentAfter) return false; + } + if (query.sentOn !== undefined) { + const d = msg.envelope.date; + const q = query.sentOn; + if ( + d.getUTCFullYear() !== q.getUTCFullYear() || + d.getUTCMonth() !== q.getUTCMonth() || + d.getUTCDate() !== q.getUTCDate() + ) { + return false; + } + } + + if (query.hasFlags !== undefined) { + for (const flag of query.hasFlags) { + if (!msg.flags.has(flag)) return false; + } + } + + if (query.missingFlags !== undefined) { + for (const flag of query.missingFlags) { + if (msg.flags.has(flag)) return false; + } + } + + if (query.largerThan !== undefined) { + if ((await readRaw()).length <= query.largerThan) return false; + } + if (query.smallerThan !== undefined) { + if ((await readRaw()).length >= query.smallerThan) return false; + } + + if (query.body !== undefined || query.text !== undefined) { + const raw = await readRaw(); + const rawText = new TextDecoder("utf-8", { fatal: false }).decode(raw); + if (query.body !== undefined) { + const { bodyOffset } = parseHeaderSection(raw); + const bodyText = new TextDecoder("utf-8", { fatal: false }).decode( + raw.slice(bodyOffset), + ); + if (!bodyText.toLowerCase().includes(query.body.toLowerCase())) { + return false; + } + } + if (query.text !== undefined) { + if (!rawText.toLowerCase().includes(query.text.toLowerCase())) { + return false; + } + } + } + + if (query.and !== undefined) { + for (const sub of query.and) { + if (!(await matchMessage(msg, sub, readRaw))) return false; + } + } + + if (query.or !== undefined) { + if (query.or.length > 0) { + let anyMatch = false; + for (const sub of query.or) { + if (await matchMessage(msg, sub, readRaw)) { + anyMatch = true; + break; + } + } + if (!anyMatch) return false; + } + } + + if (query.not !== undefined) { + if (await matchMessage(msg, query.not, readRaw)) return false; + } + + return true; +} + +const headerCache = new WeakMap>(); + +async function lazyHeaders( + msg: StoredMessage, + readRaw: () => Promise, +): Promise> { + const cached = headerCache.get(msg); + if (cached !== undefined) return cached; + const { headers } = parseHeaderSection(await readRaw()); + headerCache.set(msg, headers); + return headers; +} diff --git a/vendor/intx/mailbox/src/thread.ts b/vendor/intx/mailbox/src/thread.ts new file mode 100644 index 000000000..024557901 --- /dev/null +++ b/vendor/intx/mailbox/src/thread.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Map.get()! after has() checks in threading algorithm */ +import type { Thread, SearchQuery } from "@intx/types/runtime"; +import type { MailboxStore, StoredMessage } from "./mailbox"; +import { executeSearch } from "./search"; + +/** + * RFC 5256 REFERENCES threading algorithm. + * + * Builds parent-child relationships from In-Reply-To and References headers. + * The algorithm: + * 1. For each message, collect its References chain (oldest → newest ancestor). + * 2. Link messages into a tree using these chains. + * 3. Create dummy containers for referenced messages not present in the set. + * 4. Prune dummy containers with no children; promote children of childless dummies. + * 5. Gather root-level containers with the same base subject (skipped here — + * we implement only the parent/child linking portion which is what this + * transport needs; subject-based gathering is optional for our use case). + * 6. Sort threads at each level. + * + * Note: RFC 5256 also defines an ORDEREDSUBJECT algorithm. For that, messages + * are sorted by subject and date without reference tracking. + */ + +type Container = { + messageId: string; + message: StoredMessage | null; + parent: Container | null; + children: Container[]; +}; + +export async function executeThread( + mailboxName: string, + store: MailboxStore, + algorithm: "references" | "orderedsubject", + query?: SearchQuery, +): Promise { + let messages: StoredMessage[]; + + if (query !== undefined) { + const refs = await executeSearch(mailboxName, store, query); + const uidSet = new Set(refs.map((r) => r.uid)); + messages = store.messages.filter((m) => uidSet.has(m.uid)); + } else { + messages = [...store.messages]; + } + + if (messages.length === 0) return []; + + if (algorithm === "orderedsubject") { + return orderedSubjectThread(mailboxName, messages); + } + + return referencesThread(mailboxName, messages); +} + +/** + * RFC 5256 ORDEREDSUBJECT: sort by base subject, then date. + * All messages with the same base subject form one thread; the first by date + * is the root, the rest are direct children. + */ +function orderedSubjectThread( + mailboxName: string, + messages: StoredMessage[], +): Thread[] { + const bySubject = new Map(); + + for (const msg of messages) { + const base = baseSubject(msg.envelope.subject); + const bucket = bySubject.get(base); + if (bucket === undefined) { + bySubject.set(base, [msg]); + } else { + bucket.push(msg); + } + } + + const threads: Thread[] = []; + for (const [, msgs] of bySubject) { + const sorted = msgs.sort( + (a, b) => a.envelope.date.getTime() - b.envelope.date.getTime(), + ); + const root = sorted[0]!; + const rootThread: Thread = { + ref: { uid: root.uid, mailbox: mailboxName }, + children: sorted.slice(1).map((m) => ({ + ref: { uid: m.uid, mailbox: mailboxName }, + children: [], + })), + }; + threads.push(rootThread); + } + + return threads.sort((a, b) => { + const aMsg = messages.find((m) => m.uid === a.ref.uid)!; + const bMsg = messages.find((m) => m.uid === b.ref.uid)!; + return aMsg.envelope.date.getTime() - bMsg.envelope.date.getTime(); + }); +} + +/** + * RFC 5256 REFERENCES algorithm. + * + * Step 1: For each message, create a container. Walk its References list + * (and In-Reply-To if not already in References) and link containers + * as parent-child in left-to-right order. + * + * Step 2: Build the id_table mapping Message-IDs to containers. + * + * Step 3: Prune empty containers (those with no message). + * + * Step 4: Collect root containers. + * + * Step 5: Sort each container's children by date. + */ +function referencesThread( + mailboxName: string, + messages: StoredMessage[], +): Thread[] { + const idTable = new Map(); + + function getOrCreate(msgId: string): Container { + const existing = idTable.get(msgId); + if (existing !== undefined) return existing; + const c: Container = { + messageId: msgId, + message: null, + parent: null, + children: [], + }; + idTable.set(msgId, c); + return c; + } + + // Step 1 & 2: Build containers and link parent-child relationships. + for (const msg of messages) { + const container = getOrCreate(msg.envelope.messageId); + container.message = msg; + + // Build the reference list: References + In-Reply-To (deduplicated). + const refs = buildRefList(msg.envelope.references, msg.envelope.inReplyTo); + + // Link: refs[i] is parent of refs[i+1], last ref is parent of this message. + let prevContainer: Container | null = null; + for (const refId of refs) { + const refContainer = getOrCreate(refId); + + if ( + prevContainer !== null && + refContainer.parent === null && + !isAncestor(refContainer, prevContainer) + ) { + prevContainer.children.push(refContainer); + refContainer.parent = prevContainer; + } + + prevContainer = refContainer; + } + + // Link the last reference as parent of this message (if no circular reference). + if ( + prevContainer !== null && + container.parent === null && + !isAncestor(container, prevContainer) + ) { + prevContainer.children.push(container); + container.parent = prevContainer; + } + } + + // Step 3: Find root containers (no parent). + const roots: Container[] = []; + for (const [, c] of idTable) { + if (c.parent === null) { + roots.push(c); + } + } + + // Step 4: Prune dummy containers (containers with no message). + // A dummy with no children is dropped. + // A dummy with children: the children are promoted to the dummy's parent level. + const prunedRoots = pruneContainers(roots); + + // Step 5: Sort and convert to Thread[]. + return containersToThreads(mailboxName, prunedRoots); +} + +function buildRefList(references: string[], inReplyTo?: string): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const ref of references) { + if (ref && !seen.has(ref)) { + seen.add(ref); + result.push(ref); + } + } + + if (inReplyTo !== undefined && inReplyTo !== "" && !seen.has(inReplyTo)) { + result.push(inReplyTo); + } + + return result; +} + +function isAncestor(potentialAncestor: Container, of: Container): boolean { + let cur: Container | null = of; + while (cur !== null) { + if (cur === potentialAncestor) return true; + cur = cur.parent; + } + return false; +} + +function pruneContainers(containers: Container[]): Container[] { + const result: Container[] = []; + for (const c of containers) { + if (c.message === null && c.children.length === 0) { + // Dummy with no children: drop it. + continue; + } + if (c.message === null && c.children.length > 0) { + // Dummy with children: promote children (skip the dummy). + const promotedChildren = pruneContainers(c.children); + result.push(...promotedChildren); + } else { + // Real message: recurse into children. + c.children = pruneContainers(c.children); + result.push(c); + } + } + return result; +} + +function containerDate(c: Container): number { + if (c.message !== null) { + return c.message.envelope.date.getTime(); + } + // For dummy containers, use the earliest child date. + let earliest = Infinity; + for (const child of c.children) { + const d = containerDate(child); + if (d < earliest) earliest = d; + } + return earliest === Infinity ? 0 : earliest; +} + +function containersToThreads( + mailboxName: string, + containers: Container[], +): Thread[] { + // Sort by date of the container (or earliest descendant for dummies). + const sorted = containers.sort((a, b) => containerDate(a) - containerDate(b)); + + return sorted + .filter((c) => c.message !== null) + .map((c) => ({ + ref: { uid: c.message!.uid, mailbox: mailboxName }, + children: containersToThreads(mailboxName, c.children), + })); +} + +function baseSubject(subject: string): string { + // Strip "Re:", "Fwd:", "Fw:" prefixes (case-insensitive) repeatedly. + let s = subject.trim(); + let changed = true; + while (changed) { + changed = false; + const m = s.match(/^(?:re|fwd?)\s*:\s*/i); + if (m !== null) { + s = s.slice(m[0].length).trim(); + changed = true; + } + } + return s; +} diff --git a/vendor/intx/mailbox/tsconfig.json b/vendor/intx/mailbox/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/mailbox/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/mime/README.md b/vendor/intx/mime/README.md new file mode 100644 index 000000000..a8124247f --- /dev/null +++ b/vendor/intx/mime/README.md @@ -0,0 +1,32 @@ +# @intx/mime + +RFC 2822 message assembly and parsing. Builds multipart/signed +messages with PGP detached signatures, parses inbound wire bytes +back into structured parts, and owns the JMAP-shaped envelope the +rest of the mail pipeline depends on. + +Consumed by `@intx/mail-memory` (in-process transport), +`@intx/storage-isogit` (mail audit log), and `@intx/harness` +(sidecar mail-tool plumbing). + +```ts +import { + parseHeaderSection, + parseMultipart, + extractBoundary, +} from "@intx/mime"; + +const { headers, bodyOffset } = parseHeaderSection(rawMessageBytes); +const contentType = headers.get("content-type"); +if (contentType === undefined) throw new Error("missing Content-Type"); +const boundary = extractBoundary(contentType); +if (boundary === undefined) throw new Error("missing boundary parameter"); +const parts = parseMultipart(rawMessageBytes.subarray(bodyOffset), boundary); +``` + +For outbound mail the builder layer is the entry point: +`createOutboundMessage` produces a structured envelope, +`assembleSignedContent` canonicalises the signed body, and +`assembleMessage` joins the signed content with a detached +signature from `createDetachedSignatureFromProvider` into wire +bytes. diff --git a/vendor/intx/mime/VENDORED-FROM b/vendor/intx/mime/VENDORED-FROM new file mode 100644 index 000000000..a8b45bd19 --- /dev/null +++ b/vendor/intx/mime/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/mime) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. diff --git a/vendor/intx/mime/package.json b/vendor/intx/mime/package.json new file mode 100644 index 000000000..0d690396d --- /dev/null +++ b/vendor/intx/mime/package.json @@ -0,0 +1,34 @@ +{ + "name": "@intx/mime", + "description": "RFC 2822 message assembly and parsing with PGP detached signatures", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/crypto": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/mime/src/index.ts b/vendor/intx/mime/src/index.ts new file mode 100644 index 000000000..a2029561d --- /dev/null +++ b/vendor/intx/mime/src/index.ts @@ -0,0 +1,44 @@ +export { + assembleSignedContent, + assembleMessage, + extractAddrSpec, + formatRFC2822Date, + generateMessageId, + parseHeaderSection, + parseMimePart, + parseMultipart, + extractBoundary, + extractPartByPath, + parseMailToEmail, + extractAttachments, + buildMessageHeaders, + decodeMail, +} from "./mime"; + +export type { + MessageHeaders, + ConversationContent, + MimeAssemblyInput, + StructuredContent, + ParsedMimePart, + ParsedMimeMessage, + JMAPEmail, + JMAPAddress, + JMAPBodyValue, + JMAPBodyPart, + JMAPAttachment, +} from "./mime"; + +export { createDetachedSignatureFromProvider } from "./pgp-sign"; + +export { + createInboundMessage, + createOutboundMessage, + isMessageId, +} from "./mail-builder"; + +export type { + CreateInboundMessageOpts, + CreateOutboundMessageOpts, + InboundPayloadInput, +} from "./mail-builder"; diff --git a/vendor/intx/mime/src/mail-builder.ts b/vendor/intx/mime/src/mail-builder.ts new file mode 100644 index 000000000..65c3ff388 --- /dev/null +++ b/vendor/intx/mime/src/mail-builder.ts @@ -0,0 +1,516 @@ +/** + * Builders for InboundMessage and OutboundMessage shapes. + * + * Constructing these by hand requires assembling MessageRef, MessageHeaders, + * payload envelopes, signature status, and other mail-shaped fields that the + * transport normally produces after parsing wire bytes. These builders + * collapse that boilerplate behind two factories with sensible defaults. + * + * The builders use the parsed-shape MessageHeaders from + * @intx/types/runtime (where date is an ISO string), NOT the + * wire-shape MessageHeaders local to this package (where date is a Date + * object and headers are serialised to RFC 2822 bytes via assembleMessage). + * + * Consumers import the message types from @intx/types directly; the + * @intx/mime barrel does not re-export them. + */ + +import { type } from "arktype"; +import type { + InboundMessage, + MessageAttachment, + MessageHeaders, + MessageRef, + OutboundMessage, +} from "@intx/types/runtime"; +import { InterchangeType, SignatureStatus } from "@intx/types/runtime"; +import { generateMessageId } from "./mime"; + +/** + * Default schema version for structured payloads. Matches + * docs/MESSAGE.md § Payload Structure, which specifies "version": "1" as + * the current schema version for every Interchange payload type. Audit + * this default whenever the documented schema version increments. + */ +const DEFAULT_PAYLOAD_VERSION = "1"; + +const MESSAGE_ID_RE = /^<[^<>\s@]+@[^<>\s@]+>$/; +const ADDRESS_RE = /^[^@\s]+@[^@\s]+$/; + +const CONVERSATION_TYPE_PREFIX = "conversation."; + +// --------------------------------------------------------------------------- +// InboundMessage builder +// --------------------------------------------------------------------------- + +/** + * Structured payload envelope for an inbound message. `version` defaults to + * the current schema version per docs/MESSAGE.md. + */ +export type InboundPayloadInput = { + type: InterchangeType; + body: Record; + version?: string; +}; + +export type CreateInboundMessageOpts = { + from: string; + to: string | string[]; + + /** Plain-text body. Mutually exclusive with `payload`. */ + content?: string; + + /** Structured JSON envelope. Mutually exclusive with `content`. */ + payload?: InboundPayloadInput; + + cc?: string | string[]; + subject?: string; + + /** + * Defaults to `new Date().toISOString()`. Accepts Date or any string + * parseable by `new Date(...)`; stored as an ISO 8601 string. + */ + date?: Date | string; + + /** Defaults to `generateMessageId(from)`. Must be of the form ``. */ + messageId?: string; + + inReplyTo?: string; + references?: string[]; + listId?: string; + + /** + * Interchange-Type header value. Auto-derived from `payload.type` when a + * payload is supplied; throws if explicitly set to a value that conflicts + * with `payload.type`. + */ + interchangeType?: InterchangeType; + + correlationId?: string; + tenantId?: string; + agentId?: string; + sessionId?: string; + offeringId?: string; + schemaVersion?: string; + traceparent?: string; + tracestate?: string; + + attachments?: MessageAttachment[]; + + /** Merged with `{ uid: 1, mailbox: "INBOX" }`. */ + ref?: Partial; + + flags?: string[]; + + /** Defaults to `"missing"`. */ + signatureStatus?: SignatureStatus; +}; + +export function createInboundMessage( + opts: CreateInboundMessageOpts, +): InboundMessage { + const fn = "createInboundMessage"; + + requireAddress(opts.from, "from", fn); + const to = normalizeAndValidateAddressArray(opts.to, "to", fn); + + validateBodyExclusivity(opts.content, opts.payload, fn); + + if (opts.payload !== undefined) { + validateInterchangeType(opts.payload.type, "payload.type", fn); + if (isConversationType(opts.payload.type)) { + throw new Error( + `${fn}: conversation types must use \`content\` instead of \`payload\`; got \`payload.type\`: ${opts.payload.type}`, + ); + } + validatePayloadBody(opts.payload.body, "payload.body", fn); + if (opts.payload.version !== undefined) { + if ( + typeof opts.payload.version !== "string" || + opts.payload.version.length === 0 + ) { + throw new Error( + `${fn}: \`payload.version\`, when provided, must be a non-empty string`, + ); + } + } + } + + if (opts.interchangeType !== undefined) { + validateInterchangeType(opts.interchangeType, "interchangeType", fn); + if ( + opts.payload !== undefined && + opts.interchangeType !== opts.payload.type + ) { + throw new Error( + `${fn}: \`interchangeType\` (${opts.interchangeType}) conflicts with \`payload.type\` (${opts.payload.type})`, + ); + } + } + + if (opts.messageId !== undefined) { + validateMessageId(opts.messageId, "messageId", fn); + } + if (opts.inReplyTo !== undefined) { + validateMessageId(opts.inReplyTo, "inReplyTo", fn); + } + if (opts.references !== undefined) { + if (opts.references.length === 0) { + throw new Error( + `${fn}: \`references\`, when provided, must contain at least one entry`, + ); + } + opts.references.forEach((ref, i) => { + validateMessageId(ref, `references[${i}]`, fn); + }); + } + + const cc = + opts.cc === undefined + ? undefined + : normalizeAndValidateAddressArray(opts.cc, "cc", fn); + + rejectEmptyStringIfPresent(opts.content, "content", fn); + rejectEmptyStringIfPresent(opts.subject, "subject", fn); + rejectEmptyStringIfPresent(opts.listId, "listId", fn); + rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn); + rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn); + rejectEmptyStringIfPresent(opts.agentId, "agentId", fn); + rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn); + rejectEmptyStringIfPresent(opts.offeringId, "offeringId", fn); + rejectEmptyStringIfPresent(opts.schemaVersion, "schemaVersion", fn); + rejectEmptyStringIfPresent(opts.traceparent, "traceparent", fn); + rejectEmptyStringIfPresent(opts.tracestate, "tracestate", fn); + + if (opts.flags !== undefined) { + opts.flags.forEach((flag, i) => { + if (typeof flag !== "string" || flag.length === 0) { + throw new Error(`${fn}: \`flags[${i}]\` must be a non-empty string`); + } + }); + } + + const signatureStatus = opts.signatureStatus ?? "missing"; + const validatedStatus = SignatureStatus(signatureStatus); + if (validatedStatus instanceof type.errors) { + throw new Error( + `${fn}: \`signatureStatus\` is not a recognised SignatureStatus: ${validatedStatus.summary}`, + ); + } + + const date = normalizeDate(opts.date, "date", fn); + const messageId = opts.messageId ?? generateMessageId(opts.from); + const derivedInterchangeType = opts.interchangeType ?? opts.payload?.type; + + const headers: MessageHeaders = { from: opts.from, to, date, messageId }; + if (cc !== undefined) headers.cc = cc; + if (opts.subject !== undefined) headers.subject = opts.subject; + if (opts.inReplyTo !== undefined) headers.inReplyTo = opts.inReplyTo; + if (opts.references !== undefined) headers.references = opts.references; + if (opts.listId !== undefined) headers.listId = opts.listId; + if (derivedInterchangeType !== undefined) { + headers.interchangeType = derivedInterchangeType; + } + if (opts.correlationId !== undefined) { + headers.interchangeCorrelationId = opts.correlationId; + } + if (opts.tenantId !== undefined) headers.interchangeTenantId = opts.tenantId; + if (opts.agentId !== undefined) headers.interchangeAgentId = opts.agentId; + if (opts.sessionId !== undefined) { + headers.interchangeSessionId = opts.sessionId; + } + if (opts.offeringId !== undefined) { + headers.interchangeOfferingId = opts.offeringId; + } + if (opts.schemaVersion !== undefined) { + headers.interchangeSchemaVersion = opts.schemaVersion; + } + if (opts.traceparent !== undefined) headers.traceparent = opts.traceparent; + if (opts.tracestate !== undefined) headers.tracestate = opts.tracestate; + + if (opts.ref?.uid !== undefined) { + if ( + typeof opts.ref.uid !== "number" || + !Number.isInteger(opts.ref.uid) || + !Number.isFinite(opts.ref.uid) || + opts.ref.uid < 1 + ) { + throw new Error( + `${fn}: \`ref.uid\`, when provided, must be a positive integer (IMAP UID)`, + ); + } + } + const ref: MessageRef = { + uid: opts.ref?.uid ?? 1, + mailbox: opts.ref?.mailbox ?? "INBOX", + }; + if (typeof ref.mailbox !== "string" || ref.mailbox.length === 0) { + throw new Error( + `${fn}: \`ref.mailbox\`, when provided, must be a non-empty string`, + ); + } + + const result: InboundMessage = { + ref, + headers, + flags: opts.flags ?? [], + signatureStatus, + }; + if (opts.content !== undefined) result.content = opts.content; + if (opts.payload !== undefined) { + result.payload = { + type: opts.payload.type, + version: opts.payload.version ?? DEFAULT_PAYLOAD_VERSION, + body: opts.payload.body, + }; + } + if (opts.attachments !== undefined && opts.attachments.length > 0) { + result.attachments = opts.attachments; + } + + return result; +} + +// --------------------------------------------------------------------------- +// OutboundMessage builder +// --------------------------------------------------------------------------- + +export type CreateOutboundMessageOpts = { + to: string | string[]; + + /** Interchange payload type. Determines content vs payload semantics. */ + type: InterchangeType; + + /** Plain-text body. Mutually exclusive with `payload`. */ + content?: string; + + /** Structured JSON envelope body. Mutually exclusive with `content`. */ + payload?: Record; + + cc?: string | string[]; + subject?: string; + + /** Human-readable summary used as the text/plain part for structured types. */ + summary?: string; + + inReplyTo?: string; + references?: string[]; + correlationId?: string; + sessionId?: string; + tenantId?: string; + + attachments?: MessageAttachment[]; +}; + +export function createOutboundMessage( + opts: CreateOutboundMessageOpts, +): OutboundMessage { + const fn = "createOutboundMessage"; + + validateInterchangeType(opts.type, "type", fn); + // Validate addresses without mutating the source shape; the OutboundMessage + // type preserves `string | string[]` and downstream consumers handle both. + normalizeAndValidateAddressArray(opts.to, "to", fn); + if (opts.cc !== undefined) { + normalizeAndValidateAddressArray(opts.cc, "cc", fn); + } + + validateBodyExclusivity(opts.content, opts.payload, fn); + + if (isConversationType(opts.type)) { + if (opts.payload !== undefined) { + throw new Error( + `${fn}: conversation \`type\` ${opts.type} must use \`content\` instead of \`payload\``, + ); + } + if (opts.content === undefined) { + throw new Error( + `${fn}: conversation \`type\` ${opts.type} requires \`content\``, + ); + } + } else { + if (opts.content !== undefined) { + throw new Error( + `${fn}: non-conversation \`type\` ${opts.type} must use \`payload\` instead of \`content\``, + ); + } + if (opts.payload === undefined) { + throw new Error( + `${fn}: non-conversation \`type\` ${opts.type} requires \`payload\``, + ); + } + } + if (opts.payload !== undefined) { + validatePayloadBody(opts.payload, "payload", fn); + } + + if (opts.inReplyTo !== undefined) { + validateMessageId(opts.inReplyTo, "inReplyTo", fn); + } + if (opts.references !== undefined) { + if (opts.references.length === 0) { + throw new Error( + `${fn}: \`references\`, when provided, must contain at least one entry`, + ); + } + opts.references.forEach((ref, i) => { + validateMessageId(ref, `references[${i}]`, fn); + }); + } + rejectEmptyStringIfPresent(opts.content, "content", fn); + rejectEmptyStringIfPresent(opts.subject, "subject", fn); + rejectEmptyStringIfPresent(opts.summary, "summary", fn); + rejectEmptyStringIfPresent(opts.correlationId, "correlationId", fn); + rejectEmptyStringIfPresent(opts.sessionId, "sessionId", fn); + rejectEmptyStringIfPresent(opts.tenantId, "tenantId", fn); + + const result: OutboundMessage = { to: opts.to, type: opts.type }; + if (opts.cc !== undefined) result.cc = opts.cc; + if (opts.subject !== undefined) result.subject = opts.subject; + if (opts.content !== undefined) result.content = opts.content; + if (opts.payload !== undefined) result.payload = opts.payload; + if (opts.summary !== undefined) result.summary = opts.summary; + if (opts.attachments !== undefined && opts.attachments.length > 0) { + result.attachments = opts.attachments; + } + if (opts.inReplyTo !== undefined) result.inReplyTo = opts.inReplyTo; + if (opts.references !== undefined) result.references = opts.references; + if (opts.correlationId !== undefined) { + result.correlationId = opts.correlationId; + } + if (opts.sessionId !== undefined) result.sessionId = opts.sessionId; + if (opts.tenantId !== undefined) result.tenantId = opts.tenantId; + return result; +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function rejectEmptyStringIfPresent( + value: string | undefined, + field: string, + fn: string, +): void { + if (value !== undefined && value.length === 0) { + throw new Error( + `${fn}: \`${field}\`, when provided, must be a non-empty string`, + ); + } +} + +function requireAddress(value: unknown, field: string, fn: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${fn}: \`${field}\` must be a non-empty string`); + } + if (!ADDRESS_RE.test(value)) { + throw new Error( + `${fn}: \`${field}\` must be an RFC 5322 address of the form \`local@domain\`; got: ${value}`, + ); + } +} + +function normalizeAndValidateAddressArray( + input: string | string[], + field: string, + fn: string, +): string[] { + if (typeof input === "string") { + requireAddress(input, field, fn); + return [input]; + } + if (!Array.isArray(input) || input.length === 0) { + throw new Error( + `${fn}: \`${field}\` must contain at least one recipient address`, + ); + } + input.forEach((entry, i) => { + requireAddress(entry, `${field}[${i}]`, fn); + }); + return input; +} + +function validatePayloadBody(value: unknown, field: string, fn: string): void { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error( + `${fn}: \`${field}\` must be a plain object (got ${ + value === null ? "null" : Array.isArray(value) ? "array" : typeof value + })`, + ); + } +} + +function isConversationType(t: InterchangeType): boolean { + return t.startsWith(CONVERSATION_TYPE_PREFIX); +} + +function validateInterchangeType( + value: unknown, + field: string, + fn: string, +): void { + const validated = InterchangeType(value); + if (validated instanceof type.errors) { + throw new Error( + `${fn}: \`${field}\` is not a valid InterchangeType: ${validated.summary}`, + ); + } +} + +function validateMessageId(value: string, field: string, fn: string): void { + if (!MESSAGE_ID_RE.test(value)) { + throw new Error( + `${fn}: \`${field}\` must be an RFC 2822 message identifier of the form \`\`; got: ${value}`, + ); + } +} + +/** + * Non-throwing predicate for the RFC 2822 message-identifier form ``. + * A caller forwarding a `messageId`/`inReplyTo`/`references` value into + * `createInboundMessage` (which rejects a malformed identifier) uses this to + * decide whether the value is safe to forward: inbound mail can carry a + * headerless-derived (sha256) or otherwise malformed Message-Id that is a + * valid claim-check key but not a valid RFC identifier. + */ +export function isMessageId(value: string): boolean { + return MESSAGE_ID_RE.test(value); +} + +function normalizeDate( + input: Date | string | undefined, + field: string, + fn: string, +): string { + if (input === undefined) return new Date().toISOString(); + if (input instanceof Date) { + if (Number.isNaN(input.getTime())) { + throw new Error(`${fn}: \`${field}\` is an Invalid Date`); + } + return input.toISOString(); + } + if (typeof input !== "string" || input.length === 0) { + throw new Error( + `${fn}: \`${field}\`, when provided, must be a Date or a non-empty string`, + ); + } + const parsed = new Date(input); + if (Number.isNaN(parsed.getTime())) { + throw new Error( + `${fn}: \`${field}\` is not a parseable date string: ${input}`, + ); + } + return parsed.toISOString(); +} + +function validateBodyExclusivity( + content: unknown, + payload: unknown, + fn: string, +): void { + if (content !== undefined && payload !== undefined) { + throw new Error( + `${fn}: \`content\` and \`payload\` are mutually exclusive; provide at most one`, + ); + } +} diff --git a/vendor/intx/mime/src/mime.ts b/vendor/intx/mime/src/mime.ts new file mode 100644 index 000000000..adb9021f0 --- /dev/null +++ b/vendor/intx/mime/src/mime.ts @@ -0,0 +1,1334 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME parser uses bounded array access throughout */ +/** + * MIME byte construction and parsing for Interchange messages. + * + * Implements exactly two message shapes per MESSAGE.md: + * 1. Conversation: multipart/mixed (text/plain plus zero or more + * attachment parts) in multipart/signed + * 2. Structured: application/vnd.interchange+json in multipart/mixed in multipart/signed + * + * Produces real RFC 2822 / RFC 2046 / RFC 3156 bytes. The signed content + * part is produced in MIME canonical form (CRLF line endings) so PGP/MIME + * verification operates on the same bytes regardless of platform. + * + * RFC references verified: + * - RFC 2822 §2.1.1: lines MUST NOT exceed 998 chars; recommended 78 + * - RFC 2046 §5.1.1: boundary MUST be <= 70 chars; CRLF before each boundary + * - RFC 3156 §5: multipart/signed; protocol="application/pgp-signature"; + * micalg=pgp-sha512; first part = signed content; second part = signature + * - Message-IDs: — valid per RFC 2822 §3.6.4 (dot-atom local-part) + */ + +import { type } from "arktype"; +import { base64Decode, base64Encode } from "@intx/types"; +import type { + MessageAttachment, + MessageHeaders as ParsedMessageHeaders, + MessagePart, +} from "@intx/types/runtime"; +import { InterchangeType } from "@intx/types/runtime"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type MessageHeaders = { + from: string; + to: string[]; + cc: string[] | undefined; + date: Date; + messageId: string; + subject: string | undefined; + inReplyTo: string | undefined; + references: string[] | undefined; + mimeVersion: "1.0"; + interchangeType: string | undefined; + interchangeCorrelationId: string | undefined; + interchangeTenantId: string | undefined; + interchangeAgentId: string | undefined; + interchangeSessionId: string | undefined; + interchangeOfferingId: string | undefined; + interchangeSchemaVersion: string | undefined; + traceparent: string | undefined; + tracestate: string | undefined; +}; + +export type ConversationContent = { + kind: "conversation"; + text: string; + attachments?: MessageAttachment[]; +}; + +export type StructuredContent = { + kind: "structured"; + json: Record; + summary?: string; +}; + +export type MimeAssemblyInput = { + headers: MessageHeaders; + content: ConversationContent | StructuredContent; +}; + +export type ParsedMimePart = { + contentType: string; + headers: Map; + body: Uint8Array; +}; + +export type ParsedMimeMessage = { + headers: Map; + parts: ParsedMimePart[]; +}; + +// --------------------------------------------------------------------------- +// JMAP Email types (RFC 8621) +// --------------------------------------------------------------------------- + +export type JMAPAddress = { + name: string | null; + email: string; +}; + +export type JMAPBodyValue = { + value: string; + isEncodingProblem: boolean; +}; + +export type JMAPBodyPart = { + partId: string; + type: string; +}; + +export type JMAPAttachment = { + blobId: string; + name: string | null; + type: string; + size: number; +}; + +export type JMAPEmail = { + from: JMAPAddress[]; + to: JMAPAddress[]; + subject: string | null; + sentAt: string | null; + bodyValues: Record; + textBody: JMAPBodyPart[]; + htmlBody: JMAPBodyPart[]; + attachments: JMAPAttachment[]; + headers: Record; +}; + +// --------------------------------------------------------------------------- +// Message-ID generation +// --------------------------------------------------------------------------- + +export function generateMessageId(address: string): string { + const domain = address.includes("@") ? address.split("@")[1]! : "local"; + const uuid = crypto.randomUUID(); + return `<${uuid}@${domain}>`; +} + +// --------------------------------------------------------------------------- +// Address normalization +// --------------------------------------------------------------------------- + +/** + * Extract the bare addr-spec (local-part@domain) from a single RFC 5322 + * address value. Strips any display name and surrounding angle brackets, + * then lowercases the result so case-insensitive comparison falls out + * naturally. + * + * Accepted inputs (single-address only — do not pass comma-separated lists): + * `"Display Name" ` → `user@host` + * `Display Name ` → `user@host` + * `` → `user@host` + * `user@host` → `user@host` + * ` User@Host ` → `user@host` + * + * Rejected (throws) inputs: + * - empty or whitespace-only + * - input with no `@` + * - input that produces an empty local-part or domain + * - quoted local-parts (e.g. `"a@b"@host`) — technically valid per RFC + * 5321 §4.1.2 but rare in practice; the simple split below would + * misinterpret the inner `@`, so we refuse rather than guess + * - content after the closing `>` in an angle-bracketed form + * (e.g. `Name (comment)`) — would silently fall through to a + * misparsed bare-form attempt, so we refuse instead + * + * Per RFC 5321 §2.4 the local-part is technically case-sensitive, but no + * production system honors that; matching case-insensitively is the + * correct call for routing and identity checks. + */ +export function extractAddrSpec(addressLine: string): string { + const trimmed = addressLine.trim(); + if (trimmed === "") { + throw new Error("extractAddrSpec: address is empty"); + } + + let candidate: string; + const angleOpen = trimmed.lastIndexOf("<"); + if (angleOpen !== -1) { + // Angle-bracketed form. Require the `>` to be the trailing + // non-whitespace character so that input like `Name (comment)` + // is refused rather than re-parsed as a bare addr-spec. + if (!trimmed.endsWith(">")) { + throw new Error( + `extractAddrSpec: trailing content after '>' in ${JSON.stringify(addressLine)}`, + ); + } + candidate = trimmed.slice(angleOpen + 1, -1).trim(); + } else { + candidate = trimmed; + } + + // Reject quoted local-parts: the parser below splits on the first `@`, + // which would corrupt a quoted form whose local-part contains `@`. + if (candidate.includes('"')) { + throw new Error( + `extractAddrSpec: quoted local-parts are not supported: ${JSON.stringify(addressLine)}`, + ); + } + + const atIndex = candidate.indexOf("@"); + if (atIndex === -1) { + throw new Error( + `extractAddrSpec: address has no '@': ${JSON.stringify(addressLine)}`, + ); + } + + // Reject any further `@` in the candidate — a well-formed addr-spec + // has exactly one. Multiple `@` is either a quoted form (rejected + // above) or simply malformed. + if (candidate.indexOf("@", atIndex + 1) !== -1) { + throw new Error( + `extractAddrSpec: multiple '@' in ${JSON.stringify(addressLine)}`, + ); + } + + const local = candidate.slice(0, atIndex); + const domain = candidate.slice(atIndex + 1); + if (local === "" || domain === "") { + throw new Error( + `extractAddrSpec: empty local-part or domain in ${JSON.stringify(addressLine)}`, + ); + } + + return `${local.toLowerCase()}@${domain.toLowerCase()}`; +} + +// --------------------------------------------------------------------------- +// RFC 2822 date formatting +// --------------------------------------------------------------------------- + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +export function formatRFC2822Date(date: Date): string { + const day = DAYS[date.getUTCDay()]!; + const d = String(date.getUTCDate()).padStart(2, "0"); + const mon = MONTHS[date.getUTCMonth()]!; + const year = date.getUTCFullYear(); + const h = String(date.getUTCHours()).padStart(2, "0"); + const m = String(date.getUTCMinutes()).padStart(2, "0"); + const s = String(date.getUTCSeconds()).padStart(2, "0"); + return `${day}, ${d} ${mon} ${year} ${h}:${m}:${s} +0000`; +} + +// --------------------------------------------------------------------------- +// Boundary generation +// --------------------------------------------------------------------------- + +function generateBoundary(): string { + const bytes = new Uint8Array(18); + crypto.getRandomValues(bytes); + return ( + "----=_Part_" + + Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + ); +} + +// --------------------------------------------------------------------------- +// Header serialization (RFC 2822) +// --------------------------------------------------------------------------- + +const CRLF = "\r\n"; + +function hdr(name: string, value: string): string { + return `${name}: ${value}${CRLF}`; +} + +function serializeMessageHeaders( + h: MessageHeaders, + contentType: string, +): string { + let out = ""; + out += hdr("From", h.from); + out += hdr("To", Array.isArray(h.to) ? h.to.join(", ") : (h.to as string)); + if (h.cc && h.cc.length > 0) { + out += hdr("Cc", h.cc.join(", ")); + } + out += hdr("Date", formatRFC2822Date(h.date)); + out += hdr("Message-ID", h.messageId); + if (h.subject !== undefined) { + out += hdr("Subject", h.subject); + } + if (h.inReplyTo !== undefined) { + out += hdr("In-Reply-To", h.inReplyTo); + } + if (h.references !== undefined && h.references.length > 0) { + out += hdr("References", h.references.join(" ")); + } + out += hdr("MIME-Version", "1.0"); + out += hdr("Content-Type", contentType); + + // Interchange headers + if (h.interchangeType !== undefined) { + out += hdr("Interchange-Type", h.interchangeType); + } + if (h.interchangeCorrelationId !== undefined) { + out += hdr("Interchange-Correlation-ID", h.interchangeCorrelationId); + } + if (h.interchangeTenantId !== undefined) { + out += hdr("Interchange-Tenant-ID", h.interchangeTenantId); + } + if (h.interchangeAgentId !== undefined) { + out += hdr("Interchange-Agent-ID", h.interchangeAgentId); + } + if (h.interchangeSessionId !== undefined) { + out += hdr("Interchange-Session-ID", h.interchangeSessionId); + } + if (h.interchangeOfferingId !== undefined) { + out += hdr("Interchange-Offering-ID", h.interchangeOfferingId); + } + if (h.interchangeSchemaVersion !== undefined) { + out += hdr("Interchange-Schema-Version", h.interchangeSchemaVersion); + } + if (h.traceparent !== undefined) { + out += hdr("traceparent", h.traceparent); + } + if (h.tracestate !== undefined) { + out += hdr("tracestate", h.tracestate); + } + + return out; +} + +// --------------------------------------------------------------------------- +// MIME part assembly +// --------------------------------------------------------------------------- + +/** + * Reject values that would break out of a MIME header. CR/LF in a header + * value is a header-injection vector; a double quote breaks the quoted + * `filename="..."` / `name="..."` forms the parser relies on. The MIME + * layer owns header well-formedness, so it fails loudly here rather than + * emitting a corrupt envelope. + */ +function assertHeaderSafe(value: string, field: string): void { + if (/[\r\n]/.test(value)) { + throw new Error( + `${field} must not contain CR or LF: ${JSON.stringify(value)}`, + ); + } + if (value.includes('"')) { + throw new Error( + `${field} must not contain a double quote: ${JSON.stringify(value)}`, + ); + } +} + +/** + * Encode bytes as base64, wrapped at 76 columns per RFC 2045. Returns the + * empty string for empty input. + */ +function base64Lines(bytes: Uint8Array): string { + const b64 = base64Encode(bytes); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += 76) { + lines.push(b64.slice(i, i + 76)); + } + return lines.join(CRLF); +} + +/** + * Assemble the signed content for a conversation message. + * + * The shape is always multipart/mixed: one text/plain part (BODY[1.1]) + * followed by zero or more binary attachment parts (BODY[1.2..N]). The + * shape is unconditional — there is no bare text/plain branch — so the + * writer, the parser, and the signed-bytes contract have one form each. + * + * This is the exact bytes that will be hashed for the PGP/MIME signature. + */ +function assembleConversationSignedPart( + text: string, + attachments: readonly MessageAttachment[] = [], +): Uint8Array { + const boundary = generateBoundary(); + + // Canonicalize the text part: CRLF line endings, strip trailing + // whitespace per line. + const lines = text.split(/\r\n|\r|\n/); + const canonLines = lines.map((l) => l.replace(/[ \t]+$/, "")); + const canonical = canonLines.join(CRLF); + + let body = `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}`; + + // Text part (BODY[1.1]) + body += `--${boundary}${CRLF}`; + body += `Content-Type: text/plain; charset=utf-8${CRLF}`; + body += `Content-Transfer-Encoding: 7bit${CRLF}`; + body += `${CRLF}`; + body += `${canonical}${CRLF}`; + + // Attachment parts (BODY[1.2..N]) + for (const att of attachments) { + assertHeaderSafe(att.contentType, "attachment contentType"); + assertHeaderSafe(att.name, "attachment name"); + body += `--${boundary}${CRLF}`; + body += `Content-Type: ${att.contentType}${CRLF}`; + body += `Content-Transfer-Encoding: base64${CRLF}`; + body += `Content-Disposition: attachment; filename="${att.name}"${CRLF}`; + body += `${CRLF}`; + body += `${base64Lines(att.data)}${CRLF}`; + } + + body += `--${boundary}--${CRLF}`; + return new TextEncoder().encode(body); +} + +/** + * Assemble the signed content for a structured message (multipart/mixed). + * + * This is the exact bytes that will be hashed for the PGP/MIME signature. + */ +function assembleStructuredSignedPart( + json: Record, + summary?: string, +): Uint8Array { + const boundary = generateBoundary(); + const jsonStr = JSON.stringify(json); + + let body = `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}`; + + // JSON payload part + body += `--${boundary}${CRLF}`; + body += `Content-Type: application/vnd.interchange+json; charset=utf-8${CRLF}`; + body += `Content-Transfer-Encoding: 7bit${CRLF}`; + body += `${CRLF}`; + body += `${jsonStr}${CRLF}`; + + // Optional human-readable summary + if (summary !== undefined) { + body += `--${boundary}${CRLF}`; + body += `Content-Type: text/plain; charset=utf-8${CRLF}`; + body += `Content-Transfer-Encoding: 7bit${CRLF}`; + body += `${CRLF}`; + const lines = summary.split(/\r\n|\r|\n/); + const canonLines = lines.map((l) => l.replace(/[ \t]+$/, "")); + body += `${canonLines.join(CRLF)}${CRLF}`; + } + + body += `--${boundary}--${CRLF}`; + return new TextEncoder().encode(body); +} + +/** + * Wrap content part and PGP signature into multipart/signed per RFC 3156. + * + * RFC 3156 §5: The multipart/signed body MUST consist of exactly two parts. + * The first part contains the signed data. The second part contains the + * detached PGP signature in application/pgp-signature. + * + * The boundary delimiter lines use CRLF as required by RFC 2046. + */ +function wrapInMultipartSigned( + signedContentBytes: Uint8Array, + signatureBytes: Uint8Array, + boundary: string, +): Uint8Array { + const signedContent = new TextDecoder().decode(signedContentBytes); + const signature = new TextDecoder().decode(signatureBytes); + + const enc = new TextEncoder(); + + // Per RFC 2046: boundary delimiter = "--" + boundary parameter. + // The CRLF preceding the boundary belongs to the boundary, not the part. + // Each part is preceded by: CRLF + "--" + boundary + CRLF + // The closing delimiter: CRLF + "--" + boundary + "--" + CRLF + const body = + `--${boundary}${CRLF}` + + `${signedContent}` + + `${CRLF}--${boundary}${CRLF}` + + `Content-Type: application/pgp-signature${CRLF}` + + `${CRLF}` + + `${signature}${CRLF}` + + `--${boundary}--${CRLF}`; + + return enc.encode(body); +} + +// --------------------------------------------------------------------------- +// Full message assembly +// --------------------------------------------------------------------------- + +/** + * Assemble a complete RFC 2822 message from headers, content, and signature + * bytes. Returns the raw message bytes for storage. + * + * The signature bytes must be produced by signing the signed content part + * bytes (the result of assembleSignedContentPart below). + */ +export function assembleMessage( + headers: MessageHeaders, + signedContentBytes: Uint8Array, + signatureBytes: Uint8Array, +): Uint8Array { + const outerBoundary = generateBoundary(); + + const contentType = + `multipart/signed; protocol="application/pgp-signature"; ` + + `micalg=pgp-sha512; boundary="${outerBoundary}"`; + + const headerSection = serializeMessageHeaders(headers, contentType); + const bodyBytes = wrapInMultipartSigned( + signedContentBytes, + signatureBytes, + outerBoundary, + ); + + const enc = new TextEncoder(); + const headerBytes = enc.encode(headerSection + CRLF); + + const result = new Uint8Array(headerBytes.length + bodyBytes.length); + result.set(headerBytes, 0); + result.set(bodyBytes, headerBytes.length); + return result; +} + +/** + * Build the signed content bytes for a message. These exact bytes are + * what the CryptoProvider signs. The transport calls this, then signs, + * then calls assembleMessage with both. + */ +export function assembleSignedContent( + content: ConversationContent | StructuredContent, +): Uint8Array { + if (content.kind === "conversation") { + return assembleConversationSignedPart(content.text, content.attachments); + } + return assembleStructuredSignedPart(content.json, content.summary); +} + +// --------------------------------------------------------------------------- +// MIME parsing (for fetchHeaders, fetchStructure, fetchPart, fetchFull) +// --------------------------------------------------------------------------- + +const CRLF_CRLF = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); +const LF_LF = new Uint8Array([0x0a, 0x0a]); + +function findByteSequence(haystack: Uint8Array, needle: Uint8Array): number { + if (needle.length === 0) return 0; + const limit = haystack.length - needle.length; + outer: for (let i = 0; i <= limit; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return i; + } + return -1; +} + +/** + * Parse the header section of a raw RFC 2822 message. + * Returns a map of lowercase header names to their values, and the + * byte offset where the body starts. + */ +export function parseHeaderSection(raw: Uint8Array): { + headers: Map; + bodyOffset: number; + headerEnd: number; +} { + const headers = new Map(); + + // Search for the blank line separator in byte space so the returned + // offset is valid for Uint8Array.slice() even when headers contain + // multi-byte UTF-8 characters. + const crlfIdx = findByteSequence(raw, CRLF_CRLF); + const lfIdx = findByteSequence(raw, LF_LF); + + let bodyOffset = raw.length; + let headerEnd = raw.length; + + if (crlfIdx !== -1 && (lfIdx === -1 || crlfIdx <= lfIdx)) { + headerEnd = crlfIdx; + bodyOffset = crlfIdx + 4; + } else if (lfIdx !== -1) { + headerEnd = lfIdx; + bodyOffset = lfIdx + 2; + } + + const headerText = new TextDecoder("utf-8", { fatal: false }).decode( + raw.subarray(0, headerEnd), + ); + parseHeaders(headerText, headers); + + return { headers, bodyOffset, headerEnd }; +} + +function parseHeaders(headerSection: string, out: Map): void { + // Unfold continuation lines (lines starting with whitespace per RFC 2822). + const unfolded = headerSection + .replace(/\r\n[ \t]+/g, " ") + .replace(/\n[ \t]+/g, " "); + const lines = unfolded.split(/\r\n|\n/); + for (const line of lines) { + if (line.trim() === "") continue; + const colon = line.indexOf(":"); + if (colon === -1) continue; + const name = line.slice(0, colon).trim().toLowerCase(); + const value = line.slice(colon + 1).trim(); + // For repeated headers (like Received), keep the first value. + if (!out.has(name)) { + out.set(name, value); + } + } +} + +/** + * Extract the boundary parameter from a Content-Type header value. + */ +export function extractBoundary(contentTypeValue: string): string | undefined { + const match = + contentTypeValue.match(/boundary="([^"]+)"/i) ?? + contentTypeValue.match(/boundary=([^\s;]+)/i); + return match?.[1]; +} + +/** + * Parse a multipart body into individual parts. + * + * Each part is returned as raw bytes (headers + blank line + body) for + * further parsing. + */ +export function parseMultipart( + body: Uint8Array, + boundary: string, +): Uint8Array[] { + const text = new TextDecoder("utf-8", { fatal: false }).decode(body); + const delimiter = `--${boundary}`; + const parts: Uint8Array[] = []; + const enc = new TextEncoder(); + + let pos = 0; + while (pos < text.length) { + // Find next delimiter. + const delimIdx = text.indexOf(delimiter, pos); + if (delimIdx === -1) break; + + // Check if it's the closing delimiter. + const afterDelim = delimIdx + delimiter.length; + if (text.slice(afterDelim, afterDelim + 2) === "--") break; + + // Skip past the delimiter line (to end of CRLF or LF). + let partStart = afterDelim; + if (text[partStart] === "\r") partStart++; + if (text[partStart] === "\n") partStart++; + + // Find the next delimiter to know where this part ends. + const nextDelimIdx = text.indexOf("\n" + delimiter, partStart); + if (nextDelimIdx === -1) break; + + // Part body excludes the trailing CRLF before the next boundary. + let partEnd = nextDelimIdx; + // Account for the \n we searched for. + // We want to include only up to (but not including) the CRLF before "--boundary". + // nextDelimIdx points to the \n before the delimiter. The part ends before + // the preceding \r\n (or just \n). + if (partEnd > partStart && text[partEnd - 1] === "\r") { + partEnd--; + } + + const partText = text.slice(partStart, partEnd); + parts.push(enc.encode(partText)); + + pos = nextDelimIdx + 1; + } + + return parts; +} + +/** + * Parse a single MIME part into its headers and body. + */ +export function parseMimePart(partBytes: Uint8Array): ParsedMimePart { + const { headers, bodyOffset } = parseHeaderSection(partBytes); + const contentType = headers.get("content-type") ?? "application/octet-stream"; + const body = partBytes.slice(bodyOffset); + return { contentType, headers, body }; +} + +/** + * Extract a MIME part by dot-separated path from a multipart/signed message. + * + * Path "1" returns the signed content part (text/plain or multipart/mixed). + * Path "1.1" returns the first sub-part of the signed content (JSON payload). + * Path "2" returns the application/pgp-signature part. + * + * This follows IMAP FETCH section specifier semantics (RFC 9051). + */ +export function extractPartByPath( + raw: Uint8Array, + partPath: string, +): Uint8Array { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? ""; + + const steps = partPath.split(".").map((s) => { + const n = parseInt(s, 10); + if (isNaN(n) || n < 1) { + throw new Error(`Invalid part path segment: "${s}"`); + } + return n; + }); + + return walkParts(body, contentType, steps, 0); +} + +function walkParts( + body: Uint8Array, + contentType: string, + steps: number[], + depth: number, +): Uint8Array { + const step = steps[depth]; + if (step === undefined) { + throw new Error("Part path has no more segments"); + } + + if (!contentType.toLowerCase().startsWith("multipart/")) { + throw new Error( + `Cannot index into non-multipart content type: ${contentType}`, + ); + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) { + throw new Error(`No boundary found in Content-Type: ${contentType}`); + } + + const parts = parseMultipart(body, boundary); + if (step > parts.length) { + throw new Error(`Part ${step} does not exist (only ${parts.length} parts)`); + } + + const partBytes = parts[step - 1]!; + + if (depth + 1 === steps.length) { + return partBytes; + } + + // Need to descend further. + const part = parseMimePart(partBytes); + return walkParts(part.body, part.contentType, steps, depth + 1); +} + +// --------------------------------------------------------------------------- +// JMAP Email parsing +// --------------------------------------------------------------------------- + +/** + * Parse a RFC 2822 address value into structured JMAP address objects. + * + * Handles both "Display Name" and bare email@example.com + * forms, as well as comma-separated address lists. + */ +function parseAddressList(value: string): JMAPAddress[] { + const results: JMAPAddress[] = []; + // Split on commas that are not inside quoted strings or angle brackets. + // We handle the two common forms: + // 1. "Display Name" + // 2. Display Name + // 3. + // 4. email + const segments = splitAddressList(value); + for (const segment of segments) { + const addr = parseOneAddress(segment.trim()); + if (addr !== null) { + results.push(addr); + } + } + return results; +} + +function splitAddressList(value: string): string[] { + const segments: string[] = []; + let current = ""; + let depth = 0; + let inQuote = false; + + for (const ch of value) { + if (ch === '"' && !inQuote) { + inQuote = true; + current += ch; + } else if (ch === '"' && inQuote) { + inQuote = false; + current += ch; + } else if (ch === "<" && !inQuote) { + depth++; + current += ch; + } else if (ch === ">" && !inQuote) { + depth--; + current += ch; + } else if (ch === "," && depth === 0 && !inQuote) { + segments.push(current); + current = ""; + } else { + current += ch; + } + } + if (current.trim() !== "") { + segments.push(current); + } + return segments; +} + +function parseOneAddress(segment: string): JMAPAddress | null { + if (segment === "") return null; + + // "Display Name" or Display Name + const angleMatch = segment.match(/^(.*?)<([^>]+)>\s*$/); + if (angleMatch !== null) { + const rawName = angleMatch[1]!.trim(); + const email = angleMatch[2]!.trim(); + // Strip surrounding quotes from display name if present + const name = + rawName === "" ? null : rawName.replace(/^"(.*)"$/, "$1").trim() || null; + return { name, email }; + } + + // Bare email address + const bare = segment.trim(); + if (bare !== "") { + return { name: null, email: bare }; + } + + return null; +} + +/** + * Parse the MIME Date header into an ISO 8601 string. + * + * Returns null if the header is missing or the value cannot be parsed. + */ +function parseDateHeader(value: string | undefined): string | null { + if (value === undefined) return null; + const date = new Date(value); + if (isNaN(date.getTime())) return null; + return date.toISOString(); +} + +/** + * Decode a MIME body part, handling Content-Transfer-Encoding. + */ +function decodeBodyBytes( + body: Uint8Array, + headers: Map, +): { value: string; isEncodingProblem: boolean } { + const cte = (headers.get("content-transfer-encoding") ?? "7bit") + .trim() + .toLowerCase(); + + if (cte === "base64") { + try { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + const cleaned = raw.replace(/\s+/g, ""); + const binaryStr = atob(cleaned); + return { value: binaryStr, isEncodingProblem: false }; + } catch { + return { + value: new TextDecoder("utf-8", { fatal: false }).decode(body), + isEncodingProblem: true, + }; + } + } + + if (cte === "quoted-printable") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + return { value: decodeQuotedPrintable(raw), isEncodingProblem: false }; + } + + // 7bit, 8bit, binary — decode as UTF-8 + return { + value: new TextDecoder("utf-8", { fatal: false }).decode(body), + isEncodingProblem: false, + }; +} + +function decodeQuotedPrintable(text: string): string { + return text + .replace(/=\r\n/g, "") + .replace(/=\n/g, "") + .replace(/=([0-9A-Fa-f]{2})/g, (_match, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); +} + +/** + * Determine whether a MIME part is an attachment based on Content-Disposition + * and content type. + */ +function isAttachmentPart( + contentType: string, + headers: Map, +): boolean { + const disposition = headers.get("content-disposition") ?? ""; + if (disposition.toLowerCase().startsWith("attachment")) return true; + + const ct = contentType.toLowerCase().split(";")[0]!.trim(); + if (ct === "text/plain" || ct === "text/html") return false; + + // Non-text types are treated as attachments unless they are multipart. + if (ct.startsWith("multipart/")) return false; + + return true; +} + +function extractContentTypeMime(contentType: string): string { + return contentType.split(";")[0]!.trim().toLowerCase(); +} + +function extractFilename(headers: Map): string | null { + const disposition = headers.get("content-disposition") ?? ""; + const nameMatch = + disposition.match(/filename="([^"]+)"/i) ?? + disposition.match(/filename=([^\s;]+)/i); + if (nameMatch !== null) return nameMatch[1]!; + + const ct = headers.get("content-type") ?? ""; + const ctNameMatch = + ct.match(/name="([^"]+)"/i) ?? ct.match(/name=([^\s;]+)/i); + if (ctNameMatch !== null) return ctNameMatch[1]!; + + return null; +} + +type WalkContext = { + mailId: string; + bodyValues: Record; + textBody: JMAPBodyPart[]; + htmlBody: JMAPBodyPart[]; + attachments: JMAPAttachment[]; +}; + +/** + * Recursively walk MIME parts, populating body values and attachment lists. + * + * partPath uses IMAP-style dot-separated numbering (e.g., "1", "1.1", "2.3"). + */ +function walkMimePart( + partBytes: Uint8Array, + partPath: string, + ctx: WalkContext, +): void { + const part = parseMimePart(partBytes); + const mime = extractContentTypeMime(part.contentType); + + if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(part.contentType); + if (boundary === undefined) return; + const subParts = parseMultipart(part.body, boundary); + subParts.forEach((subPartBytes, idx) => { + walkMimePart(subPartBytes, `${partPath}.${idx + 1}`, ctx); + }); + return; + } + + if (isAttachmentPart(part.contentType, part.headers)) { + const blobId = `blob_${ctx.mailId}_${partPath}`; + ctx.attachments.push({ + blobId, + name: extractFilename(part.headers), + type: mime, + size: part.body.length, + }); + return; + } + + const decoded = decodeBodyBytes(part.body, part.headers); + ctx.bodyValues[partPath] = decoded; + + if (mime === "text/plain") { + ctx.textBody.push({ partId: partPath, type: mime }); + } else if (mime === "text/html") { + ctx.htmlBody.push({ partId: partPath, type: mime }); + } +} + +/** + * Convert raw MIME bytes into a JMAP Email-shaped object. + * + * Handles text/plain, multipart/mixed, and multipart/signed message shapes. + * For multipart/signed (RFC 3156), the signed content part (part 1) is + * parsed for body and attachments. Signature verification is not performed. + * + * @param raw - Raw RFC 2822 message bytes + * @param mailId - Opaque mail record ID used to generate blob IDs + */ +export function parseMailToEmail(raw: Uint8Array, mailId: string): JMAPEmail { + const { headers: msgHeaders, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = msgHeaders.get("content-type") ?? "text/plain"; + const mime = extractContentTypeMime(contentType); + + const ctx: WalkContext = { + mailId, + bodyValues: {}, + textBody: [], + htmlBody: [], + attachments: [], + }; + + if (mime === "multipart/signed") { + // RFC 3156: part 1 is the signed content, part 2 is the signature. + // Parse the content part through to extract body and attachments. + const boundary = extractBoundary(contentType); + if (boundary !== undefined) { + const outerParts = parseMultipart(body, boundary); + const contentPart = outerParts[0]; + if (contentPart !== undefined) { + // The content part may itself be text/plain or multipart/mixed. + // We assign it path "1" and walk it. + walkMimePart(contentPart, "1", ctx); + } + } + } else if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(contentType); + if (boundary !== undefined) { + const parts = parseMultipart(body, boundary); + parts.forEach((partBytes, idx) => { + walkMimePart(partBytes, `${idx + 1}`, ctx); + }); + } + } else { + // Single-part message (e.g. text/plain). + // Reconstruct minimal part bytes with content-type header so parseMimePart works. + const enc = new TextEncoder(); + const ctHeader = `Content-Type: ${contentType}\r\n\r\n`; + const partBytes = new Uint8Array(enc.encode(ctHeader).length + body.length); + partBytes.set(enc.encode(ctHeader), 0); + partBytes.set(body, enc.encode(ctHeader).length); + walkMimePart(partBytes, "1", ctx); + } + + // Extract Interchange-specific headers. + const interchangeHeaders: Record = {}; + for (const [name, value] of msgHeaders) { + if (name.startsWith("interchange-")) { + interchangeHeaders[name] = value; + } + } + + return { + from: parseAddressList(msgHeaders.get("from") ?? ""), + to: parseAddressList(msgHeaders.get("to") ?? ""), + subject: msgHeaders.get("subject") ?? null, + sentAt: parseDateHeader(msgHeaders.get("date")), + bodyValues: ctx.bodyValues, + textBody: ctx.textBody, + htmlBody: ctx.htmlBody, + attachments: ctx.attachments, + headers: interchangeHeaders, + }; +} + +/** + * Decode a MIME part body into raw bytes, honoring Content-Transfer-Encoding. + * + * Unlike `decodeBodyBytes` (which produces a JMAP string value), this returns + * the actual bytes for reconstructing a `MessageAttachment`. A malformed + * base64 body surfaces as a thrown error rather than a silent best-effort + * decode — attachment integrity is load-bearing. + */ +function decodeAttachmentBytes( + body: Uint8Array, + headers: Map, +): Uint8Array { + const cte = (headers.get("content-transfer-encoding") ?? "7bit") + .trim() + .toLowerCase(); + + if (cte === "base64") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + return base64Decode(raw.replace(/\s+/g, "")); + } + + if (cte === "quoted-printable") { + const raw = new TextDecoder("utf-8", { fatal: false }).decode(body); + const decoded = decodeQuotedPrintable(raw); + const out = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + out[i] = decoded.charCodeAt(i); + } + return out; + } + + if (cte === "7bit" || cte === "8bit" || cte === "binary") { + return body; + } + + throw new Error( + `decodeAttachmentBytes: unsupported content-transfer-encoding "${cte}"`, + ); +} + +/** + * Extract conversation attachments from raw message bytes as + * `MessageAttachment[]` with decoded payloads. + * + * The conversation signed content is a multipart/mixed whose first part is + * the text body and whose remaining attachment parts (Content-Disposition: + * attachment) carry the binary payloads. Returns an empty array for any + * shape without attachment parts — a bare text/plain signed part, a + * non-multipart/signed message, or a multipart/mixed with only the text + * part — so callers can use it unconditionally. + * + * Counterpart to `assembleConversationSignedPart`: assemble then extract + * round-trips a `MessageAttachment[]`. + */ +export function extractAttachments(raw: Uint8Array): MessageAttachment[] { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const mime = extractContentTypeMime(headers.get("content-type") ?? ""); + + if (mime !== "multipart/signed") return []; + const outerBoundary = extractBoundary(headers.get("content-type") ?? ""); + if (outerBoundary === undefined) return []; + + const contentPart = parseMultipart(body, outerBoundary)[0]; + if (contentPart === undefined) return []; + + const signed = parseMimePart(contentPart); + if (!extractContentTypeMime(signed.contentType).startsWith("multipart/")) { + return []; + } + const innerBoundary = extractBoundary(signed.contentType); + if (innerBoundary === undefined) return []; + + const attachments: MessageAttachment[] = []; + for (const subPartBytes of parseMultipart(signed.body, innerBoundary)) { + const subPart = parseMimePart(subPartBytes); + if (!isAttachmentPart(subPart.contentType, subPart.headers)) continue; + attachments.push({ + name: extractFilename(subPart.headers) ?? "attachment", + contentType: extractContentTypeMime(subPart.contentType), + data: decodeAttachmentBytes(subPart.body, subPart.headers), + }); + } + return attachments; +} + +// --------------------------------------------------------------------------- +// Decoded-mail model (Mail / MessagePart) — lossless inbound decoding +// --------------------------------------------------------------------------- + +function isInterchangeType(s: string): s is InterchangeType { + return !(InterchangeType(s) instanceof type.errors); +} + +/** + * Build the typed, ergonomic `MessageHeaders` subset from a parsed header map. + * Optional fields are included only when present (exactOptionalPropertyTypes- + * safe). The full, lossless header set is carried separately as `rawHeaders`. + */ +export function buildMessageHeaders( + headers: Map, +): ParsedMessageHeaders { + const from = headers.get("from") ?? ""; + const toRaw = headers.get("to") ?? ""; + const to = toRaw + ? toRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const date = headers.get("date") ?? ""; + const messageId = headers.get("message-id") ?? ""; + + const result: ParsedMessageHeaders = { from, to, date, messageId }; + + const ccRaw = headers.get("cc"); + if (ccRaw !== undefined) { + const cc = ccRaw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (cc.length > 0) result.cc = cc; + } + + const refsRaw = headers.get("references"); + if (refsRaw !== undefined) { + const refs = refsRaw.split(/\s+/).filter(Boolean); + if (refs.length > 0) result.references = refs; + } + + const inReplyTo = headers.get("in-reply-to"); + if (inReplyTo !== undefined) result.inReplyTo = inReplyTo; + + const subject = headers.get("subject"); + if (subject !== undefined) result.subject = subject; + + const listId = headers.get("list-id"); + if (listId !== undefined) result.listId = listId; + + const rawType = headers.get("interchange-type"); + if (rawType !== undefined && isInterchangeType(rawType)) { + result.interchangeType = rawType; + } + + const corrId = headers.get("interchange-correlation-id"); + if (corrId !== undefined) result.interchangeCorrelationId = corrId; + + const tenantId = headers.get("interchange-tenant-id"); + if (tenantId !== undefined) result.interchangeTenantId = tenantId; + + const agentId = headers.get("interchange-agent-id"); + if (agentId !== undefined) result.interchangeAgentId = agentId; + + const sessionId = headers.get("interchange-session-id"); + if (sessionId !== undefined) result.interchangeSessionId = sessionId; + + const offeringId = headers.get("interchange-offering-id"); + if (offeringId !== undefined) result.interchangeOfferingId = offeringId; + + const schemaVersion = headers.get("interchange-schema-version"); + if (schemaVersion !== undefined) + result.interchangeSchemaVersion = schemaVersion; + + const traceparent = headers.get("traceparent"); + if (traceparent !== undefined) result.traceparent = traceparent; + + const tracestate = headers.get("tracestate"); + if (tracestate !== undefined) result.tracestate = tracestate; + + return result; +} + +/** + * Parse every header line in the message's header section into a raw, + * lossless map of lowercased name to its ordered values. Repeated headers + * (e.g. `Received`) keep all occurrences; folded continuation lines are + * unfolded onto the preceding header. Bounded to the header section via + * `headerEnd` so the whole message body is never decoded here. + */ +function parseRawHeaders( + raw: Uint8Array, + headerEnd: number, +): Record { + const text = new TextDecoder("utf-8", { fatal: false }).decode( + raw.subarray(0, headerEnd), + ); + const out: Record = {}; + let current: { name: string; value: string } | null = null; + const flush = (): void => { + if (current === null) return; + const key = current.name.trim().toLowerCase(); + (out[key] ??= []).push(current.value.trim()); + current = null; + }; + for (const line of text.split(/\r\n|\n/)) { + if (line === "") break; + if ((line.startsWith(" ") || line.startsWith("\t")) && current !== null) { + current.value += ` ${line.trim()}`; + continue; + } + const idx = line.indexOf(":"); + if (idx === -1) continue; + flush(); + current = { name: line.slice(0, idx), value: line.slice(idx + 1) }; + } + flush(); + return out; +} + +function parseDisposition( + headers: Map, +): "inline" | "attachment" | undefined { + const d = (headers.get("content-disposition") ?? "").trim().toLowerCase(); + if (d.startsWith("attachment")) return "attachment"; + if (d.startsWith("inline")) return "inline"; + return undefined; +} + +/** + * Recursively collect the decoded leaf parts of a MIME part. A multipart part + * recurses into its children; a leaf part is decoded (transfer-encoding undone) + * into a `MessagePart`. The PGP/MIME signature part is transport plumbing, not + * content, so it is skipped -- which unwraps the `multipart/signed` envelope + * (its two children are the signed content and the signature) for free. + */ +function collectLeafParts(partBytes: Uint8Array): MessagePart[] { + const part = parseMimePart(partBytes); + const mime = extractContentTypeMime(part.contentType); + if (mime === "application/pgp-signature") return []; + if (mime.startsWith("multipart/")) { + const boundary = extractBoundary(part.contentType); + // A multipart part with no boundary is undecodable: its children cannot + // be located. Silently returning [] would drop that content and break the + // lossless contract, so surface it as a decode failure the caller drops. + if (boundary === undefined) { + throw new Error( + `decodeMail: ${mime} part has no boundary parameter; cannot decode its children`, + ); + } + return parseMultipart(part.body, boundary).flatMap(collectLeafParts); + } + const result: MessagePart = { + contentType: mime, + content: decodeAttachmentBytes(part.body, part.headers), + }; + const filename = extractFilename(part.headers); + if (filename !== null) result.filename = filename; + const disposition = parseDisposition(part.headers); + if (disposition !== undefined) result.disposition = disposition; + return [result]; +} + +/** + * Decode a raw inbound MIME message into its lossless parts: the typed header + * subset, the full raw header map, and the flat list of decoded leaf parts + * (the PGP/MIME signature and multipart wrappers removed). This is the + * in-memory form; a caller commits each part's bytes to durable storage to + * produce a JSON-safe `Mail`. Reused across the standalone and deployed + * ingest paths so both see the same decoding. + */ +export function decodeMail(raw: Uint8Array): { + headers: ParsedMessageHeaders; + rawHeaders: Record; + parts: MessagePart[]; +} { + const { headers: singleMap, headerEnd } = parseHeaderSection(raw); + const rawHeaders = parseRawHeaders(raw, headerEnd); + const headers = buildMessageHeaders(singleMap); + const parts = collectLeafParts(raw); + return { headers, rawHeaders, parts }; +} diff --git a/vendor/intx/mime/src/pgp-sign.ts b/vendor/intx/mime/src/pgp-sign.ts new file mode 100644 index 000000000..bb89b480b --- /dev/null +++ b/vendor/intx/mime/src/pgp-sign.ts @@ -0,0 +1,29 @@ +/** + * PGP/MIME signing via CryptoProvider. + * + * createDetachedSignature in @intx/crypto signs with raw private key bytes, + * but callers that only hold a CryptoProvider (which does not expose the + * private key) need this variant. It delegates to the crypto package's + * signer-function primitive, handing it the provider's raw Ed25519 sign + * operation. The OpenPGP packet assembly lives entirely in @intx/crypto; + * this module only adapts a CryptoProvider into the signer the primitive + * expects. + */ + +import { createDetachedSignatureWithSigner } from "@intx/crypto"; +import type { CryptoProvider } from "@intx/types/runtime"; + +/** + * Produce a PGP/MIME detached signature using a CryptoProvider. + * + * Mirrors createDetachedSignature from @intx/crypto but accepts a + * CryptoProvider instead of raw private key bytes. + */ +export async function createDetachedSignatureFromProvider( + content: Uint8Array, + provider: CryptoProvider, +): Promise { + return createDetachedSignatureWithSigner(content, (input) => + provider.sign(input), + ); +} diff --git a/vendor/intx/mime/tsconfig.json b/vendor/intx/mime/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/mime/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/types/README.md b/vendor/intx/types/README.md new file mode 100644 index 000000000..1ffe2e90c --- /dev/null +++ b/vendor/intx/types/README.md @@ -0,0 +1,62 @@ +# @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), run 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/content-type` — `detectResponseKind`, which classifies a + response's `Headers` as an SSE stream or a JSON body. +- `@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 + (`ToolPackageRegistrySource`, `ToolPackageAssetSource`, and its + `package` arms `ToolPackageAssetTarball` and + `ToolPackageAssetSourceTree`, unioned as `ToolPackageSource`), the + `getToolPackageSourceContentIdentity` helper that reads an entry's + content identity (tarball SRI or source tree oid), 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, plus + `isContainedEntryPath` for entry-path containment. +- `@intx/types/wire-definition-hash` — `computeWireDefinitionHash` and + the `canonicalJsonStringify` it builds on, the stable content hash a + workflow definition is frozen and compared by. +- `@intx/types/workflow-sources` — schemas for where a code-sourced + workflow definition's bytes come from: `WorkflowDefinitionRegistrySource`, + `WorkflowDefinitionAssetSource` (with its `tarball` and `source` + `package` arms), unioned as `WorkflowDefinitionSource`. diff --git a/vendor/intx/types/VENDORED-FROM b/vendor/intx/types/VENDORED-FROM new file mode 100644 index 000000000..d7f88c771 --- /dev/null +++ b/vendor/intx/types/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/types) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. diff --git a/vendor/intx/types/package.json b/vendor/intx/types/package.json new file mode 100644 index 000000000..b8d740deb --- /dev/null +++ b/vendor/intx/types/package.json @@ -0,0 +1,78 @@ +{ + "name": "@intx/types", + "description": "Runtime validators, API contract types, runtime interfaces, and sidecar wire frames for Interchange", + "version": "0.3.0", + "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" + }, + "./wire-definition-hash": { + "types": "./src/wire-definition-hash.ts", + "default": "./src/wire-definition-hash.ts" + }, + "./workflow-sources": { + "types": "./src/workflow-sources.ts", + "default": "./src/workflow-sources.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "arktype": "catalog:", + "semver": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/semver": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/types/src/agent-address.ts b/vendor/intx/types/src/agent-address.ts new file mode 100644 index 000000000..2af45e560 --- /dev/null +++ b/vendor/intx/types/src/agent-address.ts @@ -0,0 +1,34 @@ +// Run addresses are "@" where runId is the local part: the +// `run_`-prefixed identifier that names the run. 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 `parseRunAddress` — 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 RUN_PREFIX = "run_"; + +export function formatRunAddress(runId: string, domain: string): string { + return `${runId}@${domain}`; +} + +export function parseRunAddress( + address: string, +): { runId: string; domain: string } | null { + const atIdx = address.indexOf("@"); + if (atIdx <= 0) return null; + const runId = address.slice(0, atIdx); + const domain = address.slice(atIdx + 1); + if (!runId.startsWith(RUN_PREFIX)) return null; + if (domain.length === 0) return null; + return { runId, domain }; +} + +export function isRunAddress(address: string): boolean { + return parseRunAddress(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..a7f6a0e1b --- /dev/null +++ b/vendor/intx/types/src/approvals.ts @@ -0,0 +1,38 @@ +import { type } from "arktype"; + +export const ApprovalResponse = type({ + id: "string", + tenantId: "string", + anchorRunId: type("string").describe( + "The anchor run 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({ + // Optional so a plain one-time rejection (the default) stays a bare body. + // Scope 'always' records a standing rejection: the tool's ask gate is set to + // a standing deny for the run, so it is blocked without asking again. + "scope?": "'once' | 'always'", + "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.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..9f0d6bd71 --- /dev/null +++ b/vendor/intx/types/src/authz.ts @@ -0,0 +1,49 @@ +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; + // Identity of the capability consumer the decision is being made for + // (e.g. a `tool:` consumer). Empty when no consumer is in scope; + // a consumer-scoped condition fails closed against an empty consumer. + consumer: 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.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.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.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.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.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/credential-cipher.ts b/vendor/intx/types/src/credential-cipher.ts new file mode 100644 index 000000000..1e3c25350 --- /dev/null +++ b/vendor/intx/types/src/credential-cipher.ts @@ -0,0 +1,42 @@ +/** + * The pluggable seam for encrypting credential secrets at rest. + * + * Every write site (credential / oauth-client create and update) encrypts + * through this interface; the single read-for-use site decrypts through it. Both + * depend only on the interface, so the concrete implementation is chosen once at + * the composition root and swapped without touching any call site. + * + * The one basic implementation today is `createEnvKeyCredentialCipher` + * (@intx/crypto): AES-256-GCM under a single operator-provided key. A future KMS + * or envelope-encryption plugin implements this same interface and can keep key + * material inside the KMS, because the seam abstracts the whole encrypt/decrypt + * operation rather than just supplying key bytes. + * + * `aad` (additional authenticated data) binds a ciphertext to its context -- the + * row id and column -- so a blob cannot be transplanted between rows (or between + * a row's columns) and still decrypt. Every site builds the `aad` with + * `credentialAad(id, column)` so the binding is identical on write and read. + * + * `decrypt` is strict: it throws on a value that is not a ciphertext produced by + * `encrypt` rather than returning it as plaintext. A plaintext value reaching + * decrypt means a write path failed to encrypt or the row was never re-keyed -- + * a failure that must surface, not be silently served. + */ +export interface CredentialCipher { + encrypt(plaintext: string, aad: string): Promise; + decrypt(blob: string, aad: string): Promise; +} + +/** + * Build the additional-authenticated-data string binding a credential-secret + * ciphertext to the row and column it belongs to. The encoding is injective in + * `(id, column)` -- distinct pairs always produce distinct strings -- so a + * ciphertext cannot be transplanted to a row/column it was not sealed for even + * if an id contained the delimiter of a naive `id:column` scheme. The + * `"credential-secret"` tag domain-separates this use of the AEAD primitive from + * any other. Both the write and read sites (and the re-key script) MUST build + * the `aad` through this one function so the value matches. + */ +export function credentialAad(id: string, column: string): string { + return JSON.stringify(["credential-secret", id, column]); +} diff --git a/vendor/intx/types/src/credentials.ts b/vendor/intx/types/src/credentials.ts new file mode 100644 index 000000000..aad64b732 --- /dev/null +++ b/vendor/intx/types/src/credentials.ts @@ -0,0 +1,132 @@ +import { type } from "arktype"; + +import { ToolCredentialHandle } from "./package-json"; + +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); + +// A credential binding on a workflow definition maps a tool package's declared +// credential handle -- keyed `(package, handle)` against the tool-package +// declaration -- to a concrete credential resolved fresh at launch. `locator` +// is which credential namespace the name is resolved in; today only `tenant` +// exists (a tenant-owned credential, authorized by ownership). A second locator +// that resolves a principal-owned credential -- and the delegation authority +// axis it would need -- is future work, added with the code that consumes it. +export const credentialBindingLocators = ["tenant"] as const; +export type CredentialBindingLocator = + (typeof credentialBindingLocators)[number]; + +const BindingLocator = type.enumerated(...credentialBindingLocators); + +export const CredentialBinding = type({ + package: type("string").describe( + "The tool package the declared handle belongs to; matches the resolved manifest's top-level package name.", + ), + handle: ToolCredentialHandle.describe( + "The credential handle the tool package declared; unique within its package.", + ), + provider: type("string").describe( + "The provider the bound credential resolves against.", + ), + "name?": type("string").describe( + "Optional credential name, a tiebreaker when several credentials match the provider and locator.", + ), + locator: BindingLocator.describe( + "Which credential namespace the binding resolves the credential in. `tenant` resolves a tenant-owned credential by provider/name through the tenant walk-up; its use is authorized by tenant ownership.", + ), +}); +export type CredentialBinding = typeof CredentialBinding.infer; + +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 workflow run).", + ), + "name?": "string", +}); diff --git a/vendor/intx/types/src/grant-snapshot.ts b/vendor/intx/types/src/grant-snapshot.ts new file mode 100644 index 000000000..d8b304ff6 --- /dev/null +++ b/vendor/intx/types/src/grant-snapshot.ts @@ -0,0 +1,37 @@ +// Serializable projection of the deploy-time capability walk. +// +// The capability walk produces per-step grant declarations keyed by two +// `Map`s (grant strings plus a tool-grant-to-effect map) alongside the +// definition's grant requirements. Persisting that walk so a run can +// materialize grants without re-reading and re-walking a `workflow.json` +// blob needs a plain-data shape: the `Map`s flatten to arrays and records +// so the whole thing survives a JSON round-trip. +// +// `perStep[i].grantEffects` covers TOOL grants only, mirroring the walk's +// `GrantDeclarations.grantEffects`; director/capability/inference.source/ +// mail.* grants live in `grants` and carry no effect entry. +// +// `grantRequirements` is the full, unfiltered requirement list (both +// creator- and invoker-sourced). Consumers filter it by source themselves; +// the snapshot does not filter here. + +import { type } from "arktype"; + +import { grantEffects, GrantRequirement } from "./grants"; + +const Effect = type.enumerated(...grantEffects); + +const GrantWalkStepSnapshot = type({ + stepId: "string", + grants: "string[]", + grantEffects: { + "[string]": Effect, + }, +}); + +export const GrantWalkSnapshot = type({ + perStep: GrantWalkStepSnapshot.array(), + grantRequirements: GrantRequirement.array(), +}); + +export type GrantWalkSnapshot = typeof GrantWalkSnapshot.infer; 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.ts b/vendor/intx/types/src/grants.ts new file mode 100644 index 000000000..99f64a684 --- /dev/null +++ b/vendor/intx/types/src/grants.ts @@ -0,0 +1,114 @@ +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 workflow definition author), or `invoker` (delegated by whoever launched the workflow run). 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", +}).narrow((g, ctx) => { + // A grant targets exactly one of a role or a principal -- the same invariant + // the `grant_target_exactly_one` DB CHECK enforces. Rejecting both/neither + // here surfaces a malformed request as a 400 rather than a database 500. + const targets = (g.roleId != null ? 1 : 0) + (g.principalId != null ? 1 : 0); + if (targets !== 1) { + return ctx.mustBe( + "a grant with exactly one target: set roleId or principalId, not both and not neither", + ); + } + return true; +}); + +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 workflow run) -- satisfied only if that party actually holds the requested capability. Tenant-owned credential use is not a grant requirement: it is authorized by ownership at resolution and its consumer-scoping grant is stamped directly (see CREDENTIALS.md).", + ), + "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..d851f51cd --- /dev/null +++ b/vendor/intx/types/src/index.ts @@ -0,0 +1,37 @@ +export * from "./common"; +export * from "./me"; +export * from "./tenants"; +export * from "./principals"; +export * from "./roles"; +export * from "./grants"; +export * from "./grant-snapshot"; +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 "./credential-cipher"; +export * from "./mediated-credential"; +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"; +export * from "./sidecar-placement"; +export * from "./sidecar-allocation"; diff --git a/vendor/intx/types/src/instances.ts b/vendor/intx/types/src/instances.ts new file mode 100644 index 000000000..9f2786ceb --- /dev/null +++ b/vendor/intx/types/src/instances.ts @@ -0,0 +1,77 @@ +import { type } from "arktype"; +import { InvokerModelPreferences } from "./catalog"; +import { grantEffects } from "./grants"; +import { ApprovalResponse } from "./approvals"; + +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", +}); + +export const RunAuthorizationGrant = type({ + resource: "string", + action: "string", + effect: Effect, +}); + +export const RunAuthorizationResponse = type({ + runId: "string", + grants: RunAuthorizationGrant.array().describe( + "The run's effective authorization floor: each capability the run's principal holds with its resolved effect. A standing 'always' approval mutates the tool's committed grant in place at resolve time (approve-always sets 'allow', reject-always sets 'deny'), so a standing-resolved tool reads that effect directly. Read straight from the run's committed grants; complete for the source-ref deploy lineage (the shipping pipeline), whose committed grants carry every tool's effect. A pinned-tool deploy's ask floor is injected sidecar-side and is not reflected here.", + ), +}); + +export const RunApprovalsResponse = type({ + runId: "string", + approvals: ApprovalResponse.array().describe( + "The run's approval decisions, newest first, across every status. A tool an operator turned into a standing allow appears here with scope 'always' and status 'approved'.", + ), +}); 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/mediated-credential.ts b/vendor/intx/types/src/mediated-credential.ts new file mode 100644 index 000000000..21538f0b6 --- /dev/null +++ b/vendor/intx/types/src/mediated-credential.ts @@ -0,0 +1,102 @@ +// The runtime mediated-credential surface: how a resolved provider-backed +// credential reaches the consumer that uses it (a tool, or the built-in +// reactor) WITHOUT handing over the raw secret. +// +// A consumer declares a credential handle (see `ToolCredentialHandle`) and, at +// handler-init, resolves a *mediated credential* -- a handle that lets it +// authenticate against the provider without holding the secret on its own API. +// An HTTP credential mediates by exposing an authed `fetch` pinned to the +// credential's provider origin; the bearer token is injected per request and +// never surfaced. +// +// Honest scope of the mediation: it is NOT containment against hostile tool +// code. A tool that legitimately receives an http mediated credential can read +// the Authorization header the fetch sends. What mediation buys is (a) the +// secret is off the tool's declared API surface, (b) a single rotation point -- +// material is read fresh per use, so a rotation reaches every holder without +// re-shaping the handle -- and (c) consumer-scoped resolution. Confidentiality +// from the receiving tool needs process/VM isolation, a different boundary. +// +// The provider plugin owns how a handle is shaped; the acquisition of material +// (resolve a credential row, authorize, decrypt) lives on the delivery side and +// is never the plugin's decision. + +/** The current secret material behind a credential, read fresh at each use. */ +export interface CredentialMaterial { + readonly secret: string; +} + +/** + * Reads the current material for one credential. A provider handle calls this + * per use rather than capturing a snapshot, so a rotation that updates the + * underlying cell is picked up without rebuilding the handle. + */ +export type CredentialMaterialSource = () => CredentialMaterial; + +/** What a provider plugin is given to shape a mediated credential. */ +export interface CredentialShapeContext { + /** + * The provider origin the credential authenticates to (e.g. + * `https://api.github.com`). An http handle pins its requests to this origin. + */ + readonly origin: string; + /** Reads the current secret material at each use (rotation indirection). */ + readCurrentMaterial: CredentialMaterialSource; +} + +/** Fields shared by every mediated-credential variant. */ +export interface MediatedCredentialBase { + /** Discriminates the variant a consumer narrows on. */ + readonly kind: string; + /** + * Release resources the handle allocated. An http handle allocates none; a + * future key-file/socket handle would. Idempotent; run on teardown. + */ + dispose(): void | Promise; +} + +/** + * An HTTP-authenticated mediated credential: an authed `fetch` pinned to the + * credential's provider origin. A request whose resolved origin is not the + * pinned one is refused, and redirects are not followed (a 3xx is returned to + * the caller), so the bearer token is only ever sent to the pinned origin and a + * holder cannot redirect it to an attacker-chosen host. + */ +export interface HttpMediatedCredential extends MediatedCredentialBase { + readonly kind: "http"; + fetch(input: string | URL | Request, init?: RequestInit): Promise; +} + +/** + * A mediated credential handed to a consumer at resolve time. A discriminated + * union on `kind`; `http` is the only variant today. Future provider kinds + * (e.g. an ssh key-file + agent socket) extend the union with their own `kind`. + */ +export type MediatedCredential = HttpMediatedCredential; + +/** + * A provider plugin: the seam that owns how a mediated credential is shaped for + * its provider. Registered under `key`, matched against a resolved provider's + * plugin identifier. The plugin shapes a handle from a material source; it does + * not acquire material and never decides authorization -- both happen upstream, + * at the delivery boundary, before a plugin is ever consulted. + */ +export interface CredentialProvider { + readonly key: string; + shape(context: CredentialShapeContext): MediatedCredential; +} + +/** + * The runtime `credentials` capability: a sub-registry a consumer queries by + * the credential handle it declared, receiving a mediated credential. It is the + * dynamic (per-binding) axis that lives under the fixed, statically-typed + * capability map. + * + * Resolution is consumer-scoped and fail-closed: it yields a handle only for a + * credential the calling consumer is authorized to use. An unbound handle, or + * one the consumer lacks a `credential:{id}` / `use` grant for, throws. `resolve` + * is async because the authorization check is. + */ +export interface CredentialCapability { + resolve(handle: string): Promise; +} diff --git a/vendor/intx/types/src/message-id.ts b/vendor/intx/types/src/message-id.ts new file mode 100644 index 000000000..523afffe8 --- /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 -- a deployment's one addressable +// top-level run uses the local part of its mail address as its stable runId +// (see `deriveWorkflowRunId`). The two ids are distinct: this one is +// per-message, while the top-level 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..80fe2fb32 --- /dev/null +++ b/vendor/intx/types/src/package-json.ts @@ -0,0 +1,98 @@ +// 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 path from "node:path"; + +import { type } from "arktype"; + +/** + * A tool package's static declaration of one provider-backed credential it + * needs: an abstract handle plus optional scopes. Advisory only -- a request + * the workflow definition later binds to a concrete credential and the launch-time + * grant gate authorizes; a declaration consents to nothing on its own. The + * handle is the key the binding and the runtime delivery use. + */ +export const ToolCredentialHandle = type(/^[a-z0-9][a-z0-9._-]*$/); + +export const ToolCredentialDeclaration = type({ + handle: ToolCredentialHandle, + "scopes?": "string[]", +}); +export type ToolCredentialDeclaration = typeof ToolCredentialDeclaration.infer; + +/** + * The credential declarations for one package, with the unique-handle + * invariant enforced at parse time: a handle is the binding/delivery key, so a + * duplicate within a single package is a defect the upload boundary must + * reject rather than let collapse silently downstream. + */ +export const ToolCredentialDeclarationArray = + ToolCredentialDeclaration.array().narrow((decls, ctx) => { + const seen = new Set(); + for (const decl of decls) { + if (seen.has(decl.handle)) { + return ctx.mustBe( + `an array with no duplicate credential handles; "${decl.handle}" appears more than once`, + ); + } + seen.add(decl.handle); + } + return true; + }); +export type ToolCredentialDeclarationArray = + typeof ToolCredentialDeclarationArray.infer; + +/** + * Required fields plus the `interchange` extensions used to identify + * interchange packages: `tools` names the sidecar-bundle entry, `credentials` + * statically declares the provider-backed credentials the package's tools may + * need, `workflow` names the module whose evaluation produces a workflow + * package's `WorkflowDefinition`, `directors` names the module whose exports + * are the package's custom `defineDirector` factories, `loops` names the module + * whose exports are the package's `loop` `while`/`carry` functions, and + * `actions` names the module whose exports are the package's `action` handlers. + * `loops` and `actions` refs are resolved by export name at establish. + * `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", + "credentials?": ToolCredentialDeclarationArray, + "workflow?": "string", + "directors?": "string", + "loops?": "string", + "actions?": "string", + }).onUndeclaredKey("ignore"), +}).onUndeclaredKey("ignore"); +export type PackageJSON = typeof PackageJSON.infer; + +/** + * True when `entry` -- an `interchange.workflow`/`interchange.directors` + * module path relative to its package -- stays inside the package directory. + * An absolute path or a `..` traversal escapes and returns false. + * + * This is the string-level half of the loader's containment rule. The + * load-time loader (`resolveContainedEntry`) pairs it with a realpath-based + * symlink-escape check that only a materialized directory can run; the + * push-time asset validator, which has no filesystem, relies on this string + * half alone. Both boundaries call this one predicate so they cannot diverge + * on what "contained" means. The check uses POSIX path semantics so the + * result does not depend on the host's separator or cwd. + */ +export function isContainedEntryPath(entry: string): boolean { + if (path.posix.isAbsolute(entry)) { + return false; + } + const normalized = path.posix.normalize(entry); + return normalized !== ".." && !normalized.startsWith(`..${path.posix.sep}`); +} diff --git a/vendor/intx/types/src/principals.ts b/vendor/intx/types/src/principals.ts new file mode 100644 index 000000000..d70860bb2 --- /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 run).", + ), + refId: type("string").describe( + "Identifier of the underlying entity this principal stands for: the auth user id when `kind` is `user`, an agent-instance id when `kind` is `agent`, or a workflow run (`run_...`) or workflow definition (`wfd_...`) 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..f3c0a46d0 --- /dev/null +++ b/vendor/intx/types/src/providers.ts @@ -0,0 +1,56 @@ +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."; + +const apiBaseUrlDescription = + "The API origin a credential from this provider authenticates to (for example https://api.github.com). A provider that backs an origin-pinned credential must set it; OAuth-login-only providers may omit it."; + +export const CreateProvider = type({ + name: "string", + plugin: type("string").describe(pluginDescription), + "apiBaseUrl?": type("string").describe(apiBaseUrlDescription), + "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), + "apiBaseUrl?": type("string | null").describe(apiBaseUrlDescription), + "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), + "apiBaseUrl?": type("string | null").describe(apiBaseUrlDescription), + "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.ts b/vendor/intx/types/src/runtime-capabilities.ts new file mode 100644 index 000000000..374f9df7d --- /dev/null +++ b/vendor/intx/types/src/runtime-capabilities.ts @@ -0,0 +1,135 @@ +// 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"; +import type { CredentialCapability } from "./mediated-credential"; + +/** + * 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; + + /** + * Provider-backed credentials the agent's tools resolve by their declared + * handle. Unlike the other keys, its value is itself a sub-registry: the set + * of bound handles is per-deploy runtime data, not known at compile time, so + * the dynamic axis lives inside `CredentialCapability` while this outer map + * stays fixed and typed. Resolution is consumer-scoped and fail-closed. + */ + credentials: CredentialCapability; +} + +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; + }, + }; +} + +/** + * Compose a resolver that answers the keys in `overrides` from the override + * map and delegates every other key to `base`. + * + * The host uses this to add a per-bundle capability -- the consumer-scoped + * `credentials` handle, one instance per tool package -- onto a shared + * per-step base bag without re-plumbing the base's keys (`mail.transport` + * and any future shared key stay owned by the step bag). Each tool package's + * bundle receives the same base layered with ITS OWN credentials capability, + * so a package cannot resolve a handle scoped to a different package. + * + * `overrides` is snapshotted at construction, mirroring + * `createRuntimeCapabilities`, so later mutation of the input is not + * observable through `resolve`. An overridden key wired to `undefined` + * throws with the same guard as the base resolver rather than silently + * shadowing `base` with a hole -- a host that layers an undefined value + * has a wiring bug and must hear about it. + */ +export function layerRuntimeCapabilities( + base: RuntimeCapabilities, + overrides: Partial, +): RuntimeCapabilities { + const snapshot: Partial = { ...overrides }; + + return { + resolve(key: K): RuntimeCapabilityMap[K] { + if (Object.hasOwn(snapshot, key)) { + const value = snapshot[key]; + if (value === undefined) { + throw new Error( + `Runtime capability "${String(key)}" was layered as undefined; no current capability resolves to undefined`, + ); + } + return value; + } + return base.resolve(key); + }, + }; +} diff --git a/vendor/intx/types/src/runtime.ts b/vendor/intx/types/src/runtime.ts new file mode 100644 index 000000000..3ab030c2f --- /dev/null +++ b/vendor/intx/types/src/runtime.ts @@ -0,0 +1,2887 @@ +// 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; + + /** + * The RFC 5322 References chain for a threaded reply: the parent's own + * References plus the parent's Message-ID, in order. When present the + * transport ships it verbatim (after appending `inReplyTo` if it is not + * already the tail) rather than deriving a single-element `[inReplyTo]` + * chain, so a reply carries the full conversational ancestry. Absent for a + * non-reply or a reply whose parent could not be located. + */ + references?: 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. `content` is the DECODED bytes in memory (the + * transfer-encoding has already been undone). `filename` and `disposition` are + * surfaced from the part's `Content-Disposition` / `Content-Type` so a consumer + * can distinguish an inline part from a named attachment without re-parsing + * headers. + */ +export type MessagePart = { + contentType: string; + content: Uint8Array; + filename?: string; + disposition?: "inline" | "attachment"; + /** + * Original Content-Transfer-Encoding, when a producer chooses to record it. + * Not set for a decoded mail part -- `content` is already decoded, so the + * wire encoding is spent transport metadata. + */ + encoding?: string; +}; + +/** + * A single part of a persisted `Mail`. The bytes live in the durable store; + * this descriptor carries the part's metadata plus an opaque, relocation-stable + * `ref` a `MailPartReader` resolves to the part's bytes. Small UTF-8 text parts + * also carry their decoded `text` inline so a selector can read them without + * resolving. + */ +export type MailPart = { + contentType: string; + filename?: string; + disposition?: "inline" | "attachment"; + ref: string; + text?: string; +}; + +/** + * The single, environment-agnostic interface for reading a persisted mail + * part's bytes. Modeled on `BlobReader`: a consumer -- a workflow step, an + * agent tool, the agent's content-block projection -- resolves a + * `MailPart.ref` to its bytes without knowing or caring where the bytes live + * (a committed file on the sidecar, a blob in a browser runtime). The `ref` + * is opaque; the reader owns its scheme. Threaded to consumers through the + * runtime so a browser runtime can supply its own implementation. + */ +export interface MailPartReader { + /** Resolve a `MailPart.ref` to the part's decoded bytes. Throws if the ref + * is unrecognized or its bytes are missing. */ + read(ref: string): Promise; +} + +/** + * A fully decoded mail message: every header (both the typed, ergonomic subset + * and a raw catch-all with nothing dropped) plus the flat list of decoded leaf + * parts. This is the lossless representation a deployed workflow receives as + * its trigger input; a workflow programs against it directly (select headers, + * walk parts, route on content type), and an agent step projects the parts into + * model content blocks. It is JSON-safe: each part carries a `ref` (not raw + * bytes), so binary content never enters the event log; the runtime resolves a + * `ref` into a loadable `MessagePart` on demand. + */ +export type Mail = { + headers: MessageHeaders; + /** Every header, lowercased name to its ordered values; nothing dropped. */ + rawHeaders: Record; + parts: MailPart[]; +}; + +const MailShape = type({ + // Require the header fields a consumer dereferences unconditionally (the + // sender/recipient a projection reads); other header fields stay optional + // and are carried losslessly in `rawHeaders`. + headers: { + from: "string", + to: "string[]", + }, + rawHeaders: "object", + parts: type({ + contentType: "string", + ref: "string", + "filename?": "string", + "disposition?": "'inline' | 'attachment'", + "text?": "string", + }) + .onUndeclaredKey("reject") + .array(), +}).onUndeclaredKey("reject"); + +/** + * Narrow an opaque value (a workflow step input) to a `Mail`. Used at the + * `agent.send` boundary to decide whether the input is a mail-derived message + * whose parts must be projected into content blocks, or an arbitrary value + * delivered as synthesized text. The strict undeclared-key rejection keeps an + * arbitrary step value that merely carries a `parts` field from matching. + */ +export function isMail(value: unknown): value is Mail { + return !(MailShape(value) instanceof type.errors); +} + +/** + * 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; + + /** + * Permanently remove every `\Deleted` message from the mailbox. Returns the + * uids that were expunged, so a caller can report how many messages it + * consumed and which ones. + */ + expunge( + mailbox: string, + signal?: AbortSignal, + ): Promise<{ expungedUids: number[] }>; + + // --- 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" | "doom_loop"` initially, extensible as new + * failure categories surface. `"doom_loop"` marks a protective + * break the reactor took on the agent's behalf when the agent + * repeated an identical tool batch past the configured threshold; + * unlike `"reactor_fatal"` it is not an internal fault. + */ + 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.ts b/vendor/intx/types/src/sessions.ts new file mode 100644 index 000000000..1917f6789 --- /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.", + ), + runId: "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 /:runId/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.", + ), + runId: "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-allocation.ts b/vendor/intx/types/src/sidecar-allocation.ts new file mode 100644 index 000000000..ccbf59cf4 --- /dev/null +++ b/vendor/intx/types/src/sidecar-allocation.ts @@ -0,0 +1,32 @@ +export const sidecarAllocationStatuses = [ + "pending", + "provisioning", + "allocated", + "replacing", + "releasing", + "released", + "failed", +] as const; + +export type SidecarAllocationStatus = + (typeof sidecarAllocationStatuses)[number]; + +export function isSidecarAllocationDispatchable( + status: SidecarAllocationStatus, +): boolean { + switch (status) { + case "pending": + case "provisioning": + case "allocated": + case "replacing": + return true; + case "releasing": + case "released": + case "failed": + return false; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +} diff --git a/vendor/intx/types/src/sidecar-placement.ts b/vendor/intx/types/src/sidecar-placement.ts new file mode 100644 index 000000000..4f91c32cb --- /dev/null +++ b/vendor/intx/types/src/sidecar-placement.ts @@ -0,0 +1,12 @@ +import { type } from "arktype"; + +/** + * Requires a workflow to use a sidecar that is not shared with unrelated + * workflows or ordinary work while its allocation is active. + */ +export const SidecarPlacementRequirement = type({ + sharing: "'exclusive'", + "reuse?": "'never' | 'same-deployment'", +}); +export type SidecarPlacementRequirement = + typeof SidecarPlacementRequirement.infer; diff --git a/vendor/intx/types/src/sidecar.ts b/vendor/intx/types/src/sidecar.ts new file mode 100644 index 000000000..ca693e021 --- /dev/null +++ b/vendor/intx/types/src/sidecar.ts @@ -0,0 +1,1016 @@ +// 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 { GrantWalkSnapshot } from "./grant-snapshot"; +import { WireGrantRule } from "./grant-wire"; +import { + BoundedApprovalSnapshot, + ConnectorThreadState, + HarnessConfig, + InferenceEvent, + InferenceSource, +} from "./runtime"; +import { SignalKind } from "./signals"; +import { ToolPackageManifest } from "./tool-packages"; +import { WorkflowDefinitionSource } from "./workflow-sources"; + +// --------------------------------------------------------------------------- +// 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 run 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 run 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: the deployment's + * workflow child 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. + * `anchorRunId` is the anchor run the parked run belongs to; `agentAddress` + * is the anchor run's routable address the hub resolves tenancy from. + */ +export const SignalCorrelationRegisterFrame = type({ + type: "'signal.correlation.register'", + correlationId: "string", + runId: "string", + anchorRunId: "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; + +import { + WorkflowProjectionDefinition, + WorkflowProjectionWithSources, +} from "./wire-workflow"; +// Re-export the wire-step/projection contracts that moved to `./wire-workflow` +// so existing `@intx/types/sidecar` consumers keep resolving them here. Each +// name is an arktype schema, so the single re-export carries both its value and +// its inferred type. +export { WorkflowStep } from "./wire-workflow"; +export { WorkflowProjectionDefinition, WorkflowProjectionWithSources }; + +/** + * The decrypted credential material and per-handle binding descriptors + * delivered to a running agent so its tools can use provider-backed + * credentials. Secrets are decrypted hub-side and ride this payload on the + * live channel ONLY -- the deploy frame at launch, a `credentials.update` + * frame on rotation, and the child's in-memory cell. They are NEVER written to + * disk (they do not ride the git-committed grants file) and NEVER copied into + * any snapshot, event, or state -- redaction is by construction, mirroring how + * an `InferenceSource`'s `apiKey` stays off every egress type. + * + * `materials` is keyed by `credentialId` (a credential can back several handles, + * so its secret is stored once); `bindings` maps each declared tool handle to + * the credential that backs it and the consumer identity allowed to use it. + */ +export const CredentialMaterialEntry = type({ + credentialId: "string", + providerKey: "string", + origin: "string", + secret: "string", +}); +export type CredentialMaterialEntry = typeof CredentialMaterialEntry.infer; + +export const CredentialBindingDescriptor = type({ + handle: "string", + credentialId: "string", + consumer: "string", +}); +export type CredentialBindingDescriptor = + typeof CredentialBindingDescriptor.infer; + +export const CredentialDelivery = type({ + bindings: CredentialBindingDescriptor.array(), + materials: CredentialMaterialEntry.array(), +}); +export type CredentialDelivery = typeof CredentialDelivery.infer; + +/** + * The source-ref pin: where a code-sourced (npm) workflow definition's bytes + * come from (`source`) plus the frozen dependency closure the hub resolved for + * that pin (`closure`, concrete versions + integrity SRIs). The two ALWAYS + * travel together -- the sidecar re-materializes the exact `closure` from + * `source` and re-evaluates the pinned code -- so they are one co-required + * object rather than two independently-optional fields (a "source without + * closure" state could not be re-materialized and re-evaluated, and evaluating + * the pinned code from the closure is the only channel the sidecar has to the + * runnable definition). This is the same shape `WorkflowProbeRequestFrame` + * co-requires. + */ +export const SourceRefPin = type({ + source: WorkflowDefinitionSource, + closure: ToolPackageManifest, +}); +export type SourceRefPin = typeof SourceRefPin.infer; + +/** + * The frozen, fully-serializable record of a code-sourced workflow approval, + * persisted at prepare time and rehydrated to deploy the exact same definition + * later. It is the recovery input for an exclusively-placed workflow: the probe + * runs once on shared capacity at request time, its result is frozen here, and a + * ready allocation deploys THIS bundle verbatim with no re-probe. + * + * Every field is inert, secret-free data. `source`/`entry` name where the + * definition's bytes come from and the entry module the probe evaluated; + * `projection` is the inert wire projection the freeze hashed; `closure` is the + * frozen dependency closure the pin resolved to; `approvedWireHash` is the freeze + * anchor; `approvedGrants` is the approved grant set (rehydrated to a `Set` on + * the deploy hand-off). Per-step inference sources are deliberately NOT frozen + * here -- they carry credential secrets and are re-resolved from the launch + * spec's offering ids at deploy time. + */ +export const FrozenApprovalBundle = type({ + source: WorkflowDefinitionSource, + entry: "string > 0", + projection: WorkflowProjectionDefinition, + closure: ToolPackageManifest, + approvedWireHash: "string > 0", + approvedGrants: "string[]", +}); +export type FrozenApprovalBundle = typeof FrozenApprovalBundle.infer; + +/** + * A hub asset delivered inline in a source-ref frame so the sidecar can + * materialize a closure entry whose bytes live in that asset. `pack` is the + * base64-encoded git packfile the hub produced for the asset (`createPack` + * output); the sidecar checks out `commitSha` from it as plain files under + * `mountPath`, then the loader resolves each `kind:"asset"` closure entry + * against that mount. `assetId` matches the `source.assetId` the closure + * entries name. + */ +export const WorkflowSourceAssetMount = type({ + assetId: "string", + mountPath: "string", + pack: "string", + ref: "string", + commitSha: "string", +}); +export type WorkflowSourceAssetMount = typeof WorkflowSourceAssetMount.infer; + +/** + * A full workflow deploy frame. The deploy lineage is source-ref only: the + * runnable definition is the pinned code closure the sidecar re-materializes and + * evaluates from `sourceRef`, so the frame carries NO inline `definition`. It + * pins each step's inference sources and the hub-approved wire hash the child + * re-verifies its closure evaluation against, plus the source-ref-specific + * extras. The sources-cover-stepOrder coverage narrow that a projection carries + * runs on the sidecar against the closure-derived definition + * (`validateWorkflowProjection`), since the frame holds no definition to cover. + * + * This is deliberately NOT built on `WorkflowProjectionWithSources`: that shape + * (definition + sources + approved hash) is the approval/probe projection and + * stays intact for the probe surface and for each `referencedDefinitions` body, + * which still carry their own inert definition. + */ +export const AgentDeployWorkflow = type({ + // Per-step inference-source failover chains, one per step in the closure's + // `stepOrder`. Threaded to the workflow-process child so it resolves inference + // at step invocation without a hub round-trip. + sources: { "[string]": InferenceSource.array().atLeastLength(1) }, + // The hub-approved wire hash of the frozen projection -- the freeze anchor the + // hub gate wrote. The sidecar feeds it to the child as `DEFINITION_HASH`, which + // the child re-verifies its closure evaluation against. Optional on the wire + // because the frame schema does not force it; enforcement lives at runtime + // instead -- the production hub builder always stamps it and the sidecar fails + // closed if it is absent. + "approvedWireHash?": "string > 0", + // Extracted onTrigger section bodies. Each entry carries the body's inert + // definition, its own per-step inference-source pins, and its approved wire + // hash. The sidecar stages each body's `sources.json` so a body child -- + // in-process, its env lost across a restart -- resolves inference durably; the + // body definition itself is resolved in-memory from the parent's re-verified + // closure. Optional: only an onTrigger deploy carries it. + "referencedDefinitions?": WorkflowProjectionWithSources.array(), + // Initial credential material for the deployment's tools, decrypted hub-side + // and delivered on the deploy frame so it is resident before any step runs + // (closing the race where a tool resolves a credential before a push lands). + // Run-global: a credential's secret is stored once, keyed by credentialId. + // Optional -- a deploy whose definition binds no credentials omits it. + "credentials?": CredentialDelivery, + // The source-ref pin (`source` + frozen `closure`) the sidecar re-materializes + // and evaluates the pinned code from. Required: source-ref is the only deploy + // lineage, and without the pin the sidecar has no definition to run. + sourceRef: SourceRefPin, + // Source assets a `kind:"asset"` closure entry reads from, delivered inline + // (as on the probe) so the sidecar checks them out into its durable + // per-deployment source store before materializing the pin. Optional: only + // an asset-sourced deploy carries it; a registry-sourced pin fetches its + // tarballs over HTTP and delivers none. + "assets?": WorkflowSourceAssetMount.array(), +}); +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 shuts the deployment's + * supervisor down, 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; + +/** + * Push refreshed credential material to a running deployment (a rotation, or a + * revocation delivered by omitting the revoked credential's material so the + * child evicts it). Mirrors `SourcesUpdateFrame`: the sidecar routes it to the + * deployment's supervisor, which forwards it to the child's in-memory cell. + */ +export const CredentialsUpdateFrame = type({ + type: "'credentials.update'", + requestId: "string", + agentAddress: "string", + delivery: CredentialDelivery, +}); +export type CredentialsUpdateFrame = typeof CredentialsUpdateFrame.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 run 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 agent-state deploy/state flows and workflow-run restoration. + * The receiver distinguishes those paths by `repoId.kind`. + */ +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", + // Validated as a plain string, NOT the closed `PackRejectReason` enum, on + // purpose. A reject carrying a reason value a newer peer added must still pass + // `HubFrame` validation and reach the reject handler (which latches the + // transfer) rather than failing validation and being dropped -- a dropped + // reject leaves the transfer neither acked nor rejected, stalling it until the + // next disconnect. Producers still classify and construct through + // `PackRejectReason`, so a known reason is what actually gets sent today; the + // reader treats any reason as a terminal reject (surfaces it, latches). + reason: "string", + // Optional human-readable cause carried alongside the machine reason, so the + // sender's operator sees WHY (e.g. "symlink at X is not supported") instead of + // only the coarse reason. Absent on rejects that have no extra detail. + "detail?": "string", +}); +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. + * git.materialization.failed + * — a git-sourced entry could not be + * materialized from its checked-out + * subtree, or reached a loader that does + * not materialize git sources. + * 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 + * workflow child's step build env 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", + "git.materialization.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; + +// --------------------------------------------------------------------------- +// Workflow probe (bidirectional) +// --------------------------------------------------------------------------- +// +// A probe asks a connected sidecar to inspect a code-sourced workflow WITHOUT +// deploying it: materialize the frozen dependency closure, evaluate the entry +// module to a live `WorkflowDefinition`, project it to its inert needs +// surface, and return that projection plus the derived grant set and content +// hash. The request/result/error trio is correlated by `requestId`, entirely +// independent of the address maps -- a token-authed sidecar can serve a probe +// in its pre-deploy state, with no agent deployed and no routable address. + +/** + * Hub asks a connected sidecar to probe a code-sourced workflow. Correlated by + * `requestId`; the sidecar answers with `workflow.probe.result` on success or + * `workflow.probe.error` on failure, both carrying the same `requestId`. + * + * The frame carries everything the sidecar's probe child needs to run the + * probe with no further hub round-trip: + * - `source` names where the definition's bytes come from (a registry, a + * package-registry asset, or a git asset). + * - `closure` is the frozen dependency closure the hub already resolved -- + * concrete versions and integrity SRIs -- so the child materializes the + * exact tree the hub pinned. + * - `entry` is the `interchange.workflow` module path within the package + * whose evaluation produces the `WorkflowDefinition`. + * - `assets` (optional) delivers the hub assets a `kind:"asset"` closure + * entry reads from, inline. Delivery is inline rather than a separate + * streamed transfer (as the deploy path uses) because the probe is a + * single-shot request that already buffers the whole frame -- streaming + * would only add a transfer-vs-probe correlation state a one-shot has no + * use for. The sidecar caps the total inline payload and fails loud past + * it; a git-sourced asset that grows past that cap is the trigger to + * revisit streaming. + */ +export const WorkflowProbeRequestFrame = type({ + type: "'workflow.probe.request'", + requestId: "string", + source: WorkflowDefinitionSource, + closure: ToolPackageManifest, + entry: "string", + "assets?": WorkflowSourceAssetMount.array(), +}); +export type WorkflowProbeRequestFrame = typeof WorkflowProbeRequestFrame.infer; + +/** + * A connected sidecar's answer to a `workflow.probe.request`: the inert + * needs-surface projection of the probed workflow, the inert grant set derived + * from it, and the content hash of the projection. Correlated to the request + * by `requestId`. + * + * `projection` is the same closed `WorkflowProjectionDefinition` a deploy frame + * carries. `grants` is the deployment-wide inert grant surface -- the deduped, + * sorted union of every step's grant strings -- for pre-deploy operator + * inspection. `wireHash` is the hex SHA-256 of the projection's canonical JSON + * (`computeWireDefinitionHash` in `@intx/types/wire-definition-hash`), the + * deployment's content-addressed handle. + * + * `grantWalkSnapshot` is the UN-flattened capability walk the flattened + * `grants` is derived from: the per-step grant declarations (each step's grant + * strings plus its tool-grant `grantEffects` map) and the definition's full, + * unfiltered `grantRequirements`. It carries the per-step grouping and the + * effect data that `grants` discards, so a later persist step can record the + * complete grant walk rather than only its flattened union. The flattened + * `grants` stays alongside it because the operator-approval gate consumes it. + */ +export const WorkflowProbeResultFrame = type({ + type: "'workflow.probe.result'", + requestId: "string", + projection: WorkflowProjectionDefinition, + grants: "string[]", + grantWalkSnapshot: GrantWalkSnapshot, + wireHash: "string", +}); +export type WorkflowProbeResultFrame = typeof WorkflowProbeResultFrame.infer; + +/** + * A connected sidecar reports that a `workflow.probe.request` failed -- + * materialization, evaluation, projection, or hashing threw. Correlated to the + * request by `requestId`; `error` describes the failure. + */ +export const WorkflowProbeErrorFrame = type({ + type: "'workflow.probe.error'", + requestId: "string", + error: "string", +}); +export type WorkflowProbeErrorFrame = typeof WorkflowProbeErrorFrame.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) + .or(WorkflowProbeResultFrame) + .or(WorkflowProbeErrorFrame); +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(CredentialsUpdateFrame) + .or(PackPushFrame) + .or(PackDoneFrame) + .or(PackAckFrame) + .or(PackRejectFrame) + .or(SyncRequestFrame) + .or(SignalDeliverFrame) + .or(RunGrantsFrame) + .or(SignalCorrelationRegisterAckFrame) + .or(DrainDeliverFrame) + .or(WorkflowProbeRequestFrame); +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.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..537070dfc --- /dev/null +++ b/vendor/intx/types/src/tenants.ts @@ -0,0 +1,44 @@ +import { type } from "arktype"; + +import { SidecarPlacementRequirement } from "./sidecar-placement"; + +export const TenantConfig = type({ + "[string]": "unknown", + "sidecarPlacement?": SidecarPlacementRequirement, +}); +export type TenantConfig = typeof TenantConfig.infer; + +export const CreateTenant = type({ + name: "string", + slug: "string", + "parentId?": "string | null", +}); + +export const UpdateTenant = type({ + "name?": "string", + "config?": TenantConfig, +}); + +export const TenantResponse = type({ + id: "string", + name: "string", + slug: "string", + domain: "string", + "parentId?": "string | null", + "config?": TenantConfig, + 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.ts b/vendor/intx/types/src/tool-packages.ts new file mode 100644 index 000000000..fad721d3a --- /dev/null +++ b/vendor/intx/types/src/tool-packages.ts @@ -0,0 +1,312 @@ +// 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"; + +import { + ToolCredentialDeclarationArray, + isContainedEntryPath, +} from "./package-json"; + +/** + * 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 top-level manifest entry: a pinned package at its concrete resolved + * version, carrying the credential declarations harvested from the package's + * `interchange.credentials` (absent when it declares none). Only top-level + * pins contribute declarations; transitive dependencies never do, which is why + * this shape hangs off `topLevel` rather than `entries`. + */ +export const ToolPackageTopLevelEntry = type({ + name: ToolPackagePinName, + version: "string", + "credentials?": ToolCredentialDeclarationArray, +}); +export type ToolPackageTopLevelEntry = typeof ToolPackageTopLevelEntry.infer; + +/** + * The manifest's top-level entries with the no-duplicate-name invariant + * preserved -- the same guarantee `ToolPackagePinArray` gives agent-side pins. + * Versions here are concrete (already picked by the resolver), so the + * semver-range check that guards agent-side pins is unnecessary. + */ +export const ToolPackageTopLevelArray = ToolPackageTopLevelEntry.array().narrow( + (entries, ctx) => { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.name)) { + return ctx.mustBe( + `an array with no duplicate package names; "${entry.name}" appears more than once`, + ); + } + seen.add(entry.name); + } + return true; + }, +); +export type ToolPackageTopLevelArray = typeof ToolPackageTopLevelArray.infer; + +/** + * A pinned entry's bytes are fetched from an EXTERNAL npm registry at + * apply time. The sidecar's registry config maps `registry` to a URL and + * credentials. + * + * `integrity` is the SRI string ("sha512-...") the registry served for + * the picked version. The loader verifies the fetched bytes against it + * before unpacking and uses it as the content-addressed cache key. + */ +export const ToolPackageRegistrySource = type({ + kind: "'registry'", + registry: "string", + integrity: "string", +}); +export type ToolPackageRegistrySource = typeof ToolPackageRegistrySource.infer; + +/** + * The entry's bytes are a prepackaged npm tarball living at `path` inside + * the asset's checkout (the package-registry kind stores them under + * `tarballs/.tgz`). The loader reads the blob and extracts it. + * + * `integrity` is the SRI string ("sha512-...") of the tarball bytes. A + * reclassified npm tarball keeps its SRI: it is the same artifact an + * external registry would serve, so the loader verifies the read bytes + * against it and uses it as the content-addressed cache key, and a + * byte-identical tarball has one identity regardless of transport. + */ +export const ToolPackageAssetTarball = type({ + format: "'tarball'", + path: "string", + integrity: "string", +}); +export type ToolPackageAssetTarball = typeof ToolPackageAssetTarball.infer; + +/** + * The entry's bytes are a source package: the subtree at `packageDir` + * of the asset's checkout at `commitSha`, used in place (not packed). + * The loader checks the tree out and copies the subtree into the store. + * + * `packageDir` is the resolved POSIX subtree path of this package within + * the repo ("." for a single-package repo root, "packages/foo" for a + * monorepo member). It is a resolved directory, not a package name: a + * frozen materialization coordinate must not require re-resolving a + * `package.json` name against the tree at apply time. The narrow rejects + * absolute paths and `..` traversal at the boundary. + * + * `treeOid` is the git tree object id of the subtree at `commitSha` -- + * the content identity the loader verifies the checked-out subtree + * against. Unlike a tarball's `integrity`, it is a git tree oid, not an + * SRI, because a source subtree has no tarball bytes to hash. + */ +export const ToolPackageAssetSourceTree = type({ + format: "'source'", + commitSha: "string", + packageDir: type("string").narrow((dir, ctx) => + isContainedEntryPath(dir) + ? true + : ctx.mustBe("a repo-relative path with no '..' traversal"), + ), + treeOid: "string", +}); +export type ToolPackageAssetSourceTree = + typeof ToolPackageAssetSourceTree.infer; + +/** + * A pinned entry's bytes come from a hub `asset` -- a checked-out git + * repo attached to the agent at session time. `assetId` is the hub-side + * asset row id; the sidecar resolves it against the deploy pack's mount + * map to reach the asset's checkout. The package lives at a location + * within the checkout, either a prepackaged `tarball` or a `source` + * subtree, discriminated by `package.format`. + */ +export const ToolPackageAssetSource = type({ + kind: "'asset'", + assetId: "string", + package: ToolPackageAssetTarball.or(ToolPackageAssetSourceTree), +}); +export type ToolPackageAssetSource = typeof ToolPackageAssetSource.infer; + +/** + * Discriminated union over where a manifest entry's bytes come from: an + * external npm `registry`, or a hub `asset` (a git checkout holding a + * tarball or a source package). + */ +export const ToolPackageSource = ToolPackageRegistrySource.or( + ToolPackageAssetSource, +); +export type ToolPackageSource = typeof ToolPackageSource.infer; + +/** + * A closure entry's content identity, whatever its source: the tarball + * SRI for a `registry` entry or an `asset` tarball, the subtree git tree + * oid for an `asset` source package. Cache-bust keys read this rather + * than reaching into a shape-specific field. + */ +export function getToolPackageSourceContentIdentity( + source: ToolPackageSource, +): string { + if (source.kind === "registry") { + return source.integrity; + } + return source.package.format === "tarball" + ? source.package.integrity + : source.package.treeOid; +} + +/** + * A single pinned package in the closure. + * + * The entry's content identity lives on its `source` arm, because how it + * is derived and verified depends on where the bytes come from: an SRI + * over tarball bytes for the `asset` and `registry` arms. + * + * `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", + 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. + * + * `topLevel` extends the agent-side `ToolPackagePin` shape with the + * package's harvested `credentials` declarations. 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: ToolPackageTopLevelArray, + 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/wire-definition-hash.ts b/vendor/intx/types/src/wire-definition-hash.ts new file mode 100644 index 000000000..dfe9fe7a8 --- /dev/null +++ b/vendor/intx/types/src/wire-definition-hash.ts @@ -0,0 +1,82 @@ +// Content-addressed hash of a wire-projected workflow definition. +// +// The deploy gate, the install-time probe, and re-verify must all agree +// on the deployment's content handle, so they hash the exact same +// canonical form. This module is the single source of truth those call +// sites import; the hash is a hex SHA-256 of the definition's canonical +// JSON. + +import { hexEncode } from "./hex"; + +/** + * Project a value into a canonical JSON string with deterministically + * sorted object keys. Object key order and surrounding whitespace do not + * affect the output, so two structurally equal values serialize to + * byte-identical strings and therefore hash equal. + * + * Values follow `JSON.stringify`'s semantics for what JSON can represent: + * an object key whose value is `undefined`, a function, or a symbol is + * dropped, and such an array element renders as `null`. The only intended + * difference from `JSON.stringify` is the deterministic key order. So for + * JSON-representable values the output is invariant across a JSON + * round-trip; a value with a custom `toJSON` (e.g. a `Date`) is NOT + * round-trip invariant here, because this canonicalizer does not invoke + * `toJSON` -- a caller needing round-trip invariance must pass it + * already-JSON values. + */ +export function canonicalJsonStringify(value: unknown): string { + if (value === null) return "null"; + if (typeof value !== "object") { + // Primitives serialize as JSON does. A value JSON cannot represent + // (`undefined`, a function, a symbol) has no JSON text, so + // `JSON.stringify` returns `undefined`; canonicalize it to `null`, matching + // how JSON renders such a value as an array element (see below). A + // top-level such value never reaches a definition hash. + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + // JSON renders an `undefined`/function/symbol array element as `null`; the + // scalar branch above produces exactly that, so a plain recursive map keeps + // parity. + return `[${value.map((v) => canonicalJsonStringify(v)).join(",")}]`; + } + // Drop keys whose value JSON.stringify would omit (`undefined`, functions, + // symbols), mirroring how JSON serializes an object. + const entries = Object.entries(value) + .filter( + ([, v]) => + v !== undefined && typeof v !== "function" && typeof v !== "symbol", + ) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries + .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJsonStringify(v)}`) + .join(",")}}`; +} + +/** + * Compute the content hash for a wire-projected workflow definition: + * SHA-256 of the canonical JSON of the `WorkflowDefinition` projection, + * hex-encoded. This is the deployment's content-addressed handle; every + * party that binds identity, approval, or re-verify to a deployment + * derives it from the same canonical form so their values compare by + * byte equality. + * + * Invariance across the boundary is load-bearing: the hub hashes a + * projection parsed off the JSON wire (where `undefined`-valued keys are + * already gone) while a child hashes an in-memory projection, and the two + * must produce byte-identical strings or re-verify would fail on a + * legitimately-approved definition. The canonicalizer's Date/`toJSON` + * caveat is moot here: both sides hash a projection parsed from the JSON + * wire, so no custom-`toJSON` value ever reaches the canonicalizer and the + * two sides agree. + */ +export async function computeWireDefinitionHash( + definition: unknown, +): Promise { + const canonical = canonicalJsonStringify(definition); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(canonical), + ); + return hexEncode(new Uint8Array(digest)); +} diff --git a/vendor/intx/types/src/wire-workflow.ts b/vendor/intx/types/src/wire-workflow.ts new file mode 100644 index 000000000..694da37ba --- /dev/null +++ b/vendor/intx/types/src/wire-workflow.ts @@ -0,0 +1,167 @@ +// Wire contracts for a workflow definition projected onto an `agent.deploy` +// frame: the per-step schema and the projection shapes the sidecar deploy +// router and the workflow-process child validate. Extracted from `sidecar.ts` +// so both files stay focused; `sidecar.ts` re-exports the public names, so +// `@intx/types/sidecar` consumers are unaffected. + +import { type } from "arktype"; + +import { CredentialBinding } from "./credentials"; +import { InferenceSource } from "./runtime"; + +/** + * Fields every wire step carries regardless of `kind`. All other keys pass + * through unmodified (arktype's default), including the nested `agent`, inner + * `step`, `body`, `on`, and selector fields -- typed nowhere here on purpose, + * because two producers feed this schema: the live-deploy passthrough ships a + * step whose `agent.toolFactories` are functions that JSON-encode to `null`, + * while the live->inert projector in `@intx/workflow-deploy` ships a reified + * plain-data agent. Both must validate; reifying the grant surface into plain + * data is the projector's job, not this envelope's. + */ +const commonStepFields = { + "id?": "string", + "after?": "string[]", +} as const; + +/** + * A wire step: its `kind` must be one of the ten known primitives, plus the + * common `id`/`after` fields; all other keys pass through unmodified. Exported + * so the live->inert projector's producer and its mutation-test suite validate + * a single step against the same schema the deploy frame applies to every step. + * + * The load-bearing check here is the KIND discriminant -- a step with no `kind` + * or a `kind` outside the set is rejected at this boundary rather than carried + * through opaque, and the membership is what makes a step's canonical JSON + * deterministic across the child->hub boundary. Per-variant field validation is + * deliberately NOT done here (deeper authoring-time validation -- required + * fields, selector resolvability, DAG shape -- lives on `@intx/workflow`), so + * the ten variants collapse to one schema over the kind enum rather than ten + * near-identical arms whose fields were all optional passthrough anyway. + */ +export const WorkflowStep = type({ + kind: "'step' | 'map' | 'gate' | 'awaitSignal' | 'sleep' | 'childWorkflow' | 'escalation' | 'action' | 'loop' | 'onTrigger'", + ...commonStepFields, +}); +export type WorkflowStep = typeof WorkflowStep.infer; + +/** + * The `steps` record on a wire projection: every value must validate + * against the closed `WorkflowStep` union. The runtime constraint runs + * through a `.narrow` over a `Record` rather than a typed + * `{ "[string]": WorkflowStep }` on purpose: the inferred type stays + * `Record` so the existing live-deploy producer + * (`toWireWorkflowDefinition`, which hands a `Record` + * steps map to `sendAgentDeploy`) still typechecks, while the runtime + * validation is fully closed over the primitive-kind set. + */ +const WorkflowSteps = type({ "[string]": "unknown" }).narrow((steps, ctx) => { + for (const [stepId, step] of Object.entries(steps)) { + const parsed = WorkflowStep(step); + if (parsed instanceof type.errors) { + return ctx.mustBe( + `a record whose every step matches a known workflow primitive ` + + `variant; step ${JSON.stringify(stepId)} did not (${parsed.summary})`, + ); + } + } + return true; +}); + +/** + * 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 — this projection is the approved surface + * the source-ref child re-verifies its closure-evaluated definition + * against, 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. + */ +export const WorkflowProjectionDefinition = type({ + id: "string > 0", + triggers: "unknown[]", + stepOrder: "string[]", + steps: WorkflowSteps, + "state?": "Record", + // The definition's credential bindings, projected verbatim by the + // live->inert projector (`projectDefinition`). This MUST stay in sync with + // that projector: because of the `"+": "delete"` below, a binding the + // projector emits but this schema omits would be silently stripped at the + // wire boundary, desyncing the hub-resolved bindings from the projection + // the sidecar validates and re-verifies. Bindings are the operator-approved + // credential request surface (no secret material), so they belong in the + // hashed projection. + "credentialBindings?": CredentialBinding.array(), + "+": "delete", +}).narrow((value, ctx) => { + // Every `stepOrder` entry must name a defined step. A legitimately projected + // definition always satisfies this (the authoring validator enforces it), so + // this rejects only a projector-bypassing or tampered wire frame -- closing a + // phantom-stepOrder entry at the trust boundary for every consumer, rather + // than letting a downstream reader index `steps[missing]` as `undefined` and + // silently take a default path. + for (const stepId of value.stepOrder) { + if (!Object.prototype.hasOwnProperty.call(value.steps, stepId)) { + return ctx.mustBe( + `a workflow projection whose stepOrder names only defined steps; ${JSON.stringify(stepId)} has no matching entry in steps`, + ); + } + } + return true; +}); +export type WorkflowProjectionDefinition = + typeof WorkflowProjectionDefinition.infer; + +/** + * A workflow projection paired with its per-step inference-source pins and the + * hub-approved wire hash, with the invariant that every `stepOrder` entry has a + * `sources` failover chain. This is the shared base for BOTH the top-level + * deploy frame (`AgentDeployWorkflow`, which intersects its extras onto this) + * AND each extracted onTrigger body under `referencedDefinitions` -- so the + * field set and the coverage narrow are defined once and a body's sources cover + * the body's stepOrder just as the top-level's cover the top-level's. + */ +export const WorkflowProjectionWithSources = type({ + definition: WorkflowProjectionDefinition, + sources: { "[string]": InferenceSource.array().atLeastLength(1) }, + // The hub-approved wire hash of `definition`'s projection -- the freeze anchor + // the hub gate wrote (`computeWireDefinitionHash`). The sidecar feeds it to + // the child as the `DEFINITION_HASH` it re-verifies its own recompute + // against, rather than trusting a sidecar-computed hash. At the top level it + // pins the deployment's content handle; per body it pins the body's + // projection, which is re-verified in-memory as part of the parent's + // already-re-verified closure. Optional on the wire because the frame schema + // does not force it; the production hub builder always stamps it. + "approvedWireHash?": "string > 0", +}).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 WorkflowProjectionWithSources = + typeof WorkflowProjectionWithSources.infer; 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..7eee721a2 --- /dev/null +++ b/vendor/intx/types/src/workflow-run-id.ts @@ -0,0 +1,40 @@ +// Canonical runId derivation for a workflow deployment's top-level run. +// +// A workflow deployment has ONE addressable top-level run, whose stable runId +// is the local part of the deployment's mail address -- the `` in +// `@`. 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 +// trigger occurrence. Internal section/body runs still receive their own +// synthetic run ids and are not externally addressable. + +import { parseRunAddress } from "./agent-address"; + +/** + * The stable runId for a workflow deployment's one addressable top-level run: + * the local part of its mail address, before the `@`. 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. + * + * Delegates to `parseRunAddress` so a single function owns the `@`-split: the + * runId is the parsed local part. A malformed address (no `run_` marker, no + * `@`, or an empty domain) is a caller bug, not a value to key state under, so + * this throws rather than returning a fabricated id that would land run state + * under an id the supervisor never looks up. + */ +export function deriveWorkflowRunId(address: string): string { + const parsed = parseRunAddress(address); + if (parsed === null) { + throw new Error(`Invalid run address: ${JSON.stringify(address)}`); + } + return parsed.runId; +} diff --git a/vendor/intx/types/src/workflow-sources.ts b/vendor/intx/types/src/workflow-sources.ts new file mode 100644 index 000000000..4a9313787 --- /dev/null +++ b/vendor/intx/types/src/workflow-sources.ts @@ -0,0 +1,74 @@ +// Schemas for where a code-sourced workflow definition's bytes come from. +// +// A workflow install carries a `WorkflowDefinitionSource` to say where the +// definition should be fetched from at apply time. Two origins exist: the +// `registry` variant names an EXTERNAL npm registry that publishes the +// definition package; the `asset` variant names a hub asset -- a checked-out +// git repo -- that holds the definition either as a published `tarball` +// (selected by the install pin) or as a `source` codebase at a pinned commit. + +import { type } from "arktype"; + +/** + * A workflow definition published to an external npm registry, fetched at + * apply time. The sidecar's registry config maps `registry` to a URL and + * credentials. The install call's version pin selects the definition. + */ +export const WorkflowDefinitionRegistrySource = type({ + kind: "'registry'", + registry: "string", +}); +export type WorkflowDefinitionRegistrySource = + typeof WorkflowDefinitionRegistrySource.infer; + +/** + * The definition is a published tarball inside the hub asset. This names + * only the format: the install call's version pin selects which package + * inside the asset is the definition, exactly as the `registry` variant + * leaves the pin to travel separately. + */ +export const WorkflowDefinitionAssetTarball = type({ + format: "'tarball'", +}); +export type WorkflowDefinitionAssetTarball = + typeof WorkflowDefinitionAssetTarball.infer; + +/** + * The definition is the codebase in the hub asset's git checkout at a pinned + * commit. `commitSha` IS the pin, and the content hash of the tree at that + * commit is the definition's identity (the member `package.json` version is + * only an advisory label). `packageName` selects which workspace member of a + * monorepo codebase is the definition, by its `package.json` name; absent + * means the codebase is a single package rooted at the tree. + */ +export const WorkflowDefinitionAssetSourceTree = type({ + format: "'source'", + commitSha: "string", + "packageName?": "string", +}); +export type WorkflowDefinitionAssetSourceTree = + typeof WorkflowDefinitionAssetSourceTree.infer; + +/** + * A workflow definition sourced from a hub `asset` -- a checked-out git repo. + * The definition lives inside it either as a published `tarball` or as a + * `source` codebase, discriminated by `package.format`. + */ +export const WorkflowDefinitionAssetSource = type({ + kind: "'asset'", + assetId: "string", + package: WorkflowDefinitionAssetTarball.or(WorkflowDefinitionAssetSourceTree), +}); +export type WorkflowDefinitionAssetSource = + typeof WorkflowDefinitionAssetSource.infer; + +/** + * Discriminated union over where a workflow definition's bytes come from, + * keyed on `kind`. Widen it here and every by-value consumer + * (`SourceRefPin`, the probe/deploy wire frames) follows; a consumer that + * switches on `kind` gains a compile error for any unhandled variant. + */ +export const WorkflowDefinitionSource = WorkflowDefinitionRegistrySource.or( + WorkflowDefinitionAssetSource, +); +export type WorkflowDefinitionSource = typeof WorkflowDefinitionSource.infer; diff --git a/vendor/intx/types/src/workflows.ts b/vendor/intx/types/src/workflows.ts new file mode 100644 index 000000000..7677f9ccf --- /dev/null +++ b/vendor/intx/types/src/workflows.ts @@ -0,0 +1,48 @@ +// Status vocabulary for the first-class workflow definition model, kept in its +// own workflow-scoped module. +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. +export const WorkflowRollbackRequest = type({ + version: "string", +}); diff --git a/vendor/intx/types/tsconfig.json b/vendor/intx/types/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/types/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/workflow-host/VENDORED-FROM b/vendor/intx/workflow-host/VENDORED-FROM index b4a95144e..3fcca01bd 100644 --- a/vendor/intx/workflow-host/VENDORED-FROM +++ b/vendor/intx/workflow-host/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow-host) Commit: b5580a02fb918eebccc33ded7727ffee781ffbd1 (tag v0.3.0) License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6164: the supervisor's signal.deliver branch drops mail whose extracted conversation body is empty (the new hasConversationText gate in conversation-text.ts), recording an empty_conversation_content rejection, instead of delivering "" -- which throws in agent.send and kills the run with StepFailed/retriesExhausted. Attachments-only conversation.message mail (e.g. @corbits/chat's workbench.agent-joined event send) is exactly that shape. CL-6325: adds the action-primitive adapters (adapters/action-invoker.ts, adapters/effect-ledger.ts, adapters/run-blobs.ts and their tests) -- copied from gtm-workbench's packages/workflow-host workspace fork, not from upstream, which has no action-primitive adapters at the pinned commit (see VENDORED.md) -- and the completed run-child bind in child/run-child.ts: RunWorkflowChildBindings gains resolveActionHandler (awaited once per child with the re-verified definition and the live CredentialWiring) and loopFns (both defaulting to the fail-closed empty registries), and buildRuntimeEnv wires effects, invokeAction, loopFns, and runLoopIteration (createLoopIteration) into every run's WorkflowRuntimeEnv. buildRuntimeEnv is exported (child/index.ts, index.ts) so a host's runtime-env-level probe can exercise the bind without the full control-channel harness. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6164: the supervisor's signal.deliver branch drops mail whose extracted conversation body is empty (the new hasConversationText gate in conversation-text.ts), recording an empty_conversation_content rejection, instead of delivering "" -- which throws in agent.send and kills the run with StepFailed/retriesExhausted. Attachments-only conversation.message mail (e.g. @corbits/chat's workbench.agent-joined event send) is exactly that shape. CL-6325: adds the action-primitive adapters (adapters/action-invoker.ts, adapters/effect-ledger.ts, adapters/run-blobs.ts and their tests) -- copied from gtm-workbench's packages/workflow-host workspace fork, not from upstream, which has no action-primitive adapters at the pinned commit (see VENDORED.md) -- and the completed run-child bind in child/run-child.ts: RunWorkflowChildBindings gains resolveActionHandler (awaited once per child with the re-verified definition and the live CredentialWiring) and loopFns (both defaulting to the fail-closed empty registries), and buildRuntimeEnv wires effects, invokeAction, loopFns, and runLoopIteration (createLoopIteration) into every run's WorkflowRuntimeEnv. buildRuntimeEnv is exported (child/index.ts, index.ts) so a host's runtime-env-level probe can exercise the bind without the full control-channel harness. Bridging edit against the re-pinned @intx/types (a8bc06ae): child/supervisor-backed-transport.ts's expunge stub returns Promise<{ expungedUids: number[] }> (upstream bcabb1f8); gone with this tree's own re-pin. diff --git a/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts b/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts index d719015ed..6738dc690 100644 --- a/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts +++ b/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts @@ -169,7 +169,10 @@ export function createSupervisorBackedTransport( ): Promise { return inboundUnsupported("copy"); }, - async expunge(_mailbox: string, _signal?: AbortSignal): Promise { + async expunge( + _mailbox: string, + _signal?: AbortSignal, + ): Promise<{ expungedUids: number[] }> { return inboundUnsupported("expunge"); }, diff --git a/workflows/assistant/package.json b/workflows/assistant/package.json index bc1e90232..e51ec94b9 100644 --- a/workflows/assistant/package.json +++ b/workflows/assistant/package.json @@ -21,8 +21,8 @@ }, "dependencies": { "@corbits/catalog-tools": "workspace:*", - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*" }, "devDependencies": { diff --git a/workflows/attio-task-agent/package.json b/workflows/attio-task-agent/package.json index fc5e05836..e9163be09 100644 --- a/workflows/attio-task-agent/package.json +++ b/workflows/attio-task-agent/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/code-review/package.json b/workflows/code-review/package.json index 0e475682d..f5141b06c 100644 --- a/workflows/code-review/package.json +++ b/workflows/code-review/package.json @@ -21,8 +21,8 @@ }, "dependencies": { "@corbits/code-review": "workspace:*", - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*" }, "devDependencies": { diff --git a/workflows/collateral-generation/package.json b/workflows/collateral-generation/package.json index 11cc7a77b..6d4afa812 100644 --- a/workflows/collateral-generation/package.json +++ b/workflows/collateral-generation/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/diligence-brief/package.json b/workflows/diligence-brief/package.json index af9783c6d..c905dd636 100644 --- a/workflows/diligence-brief/package.json +++ b/workflows/diligence-brief/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/echo/package.json b/workflows/echo/package.json index fcfa518bd..a65ddb6bc 100644 --- a/workflows/echo/package.json +++ b/workflows/echo/package.json @@ -20,7 +20,7 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*" }, "devDependencies": { diff --git a/workflows/exa-topic-watch/package.json b/workflows/exa-topic-watch/package.json index 4b96cfe32..a5a898d54 100644 --- a/workflows/exa-topic-watch/package.json +++ b/workflows/exa-topic-watch/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/granola-call/package.json b/workflows/granola-call/package.json index 31456b1bf..0ab269c6f 100644 --- a/workflows/granola-call/package.json +++ b/workflows/granola-call/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/heartbeat/package.json b/workflows/heartbeat/package.json index 1fe492706..da330562a 100644 --- a/workflows/heartbeat/package.json +++ b/workflows/heartbeat/package.json @@ -20,7 +20,7 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*" }, "devDependencies": { diff --git a/workflows/last-30-days-research/package.json b/workflows/last-30-days-research/package.json index d9588e70c..3c594681b 100644 --- a/workflows/last-30-days-research/package.json +++ b/workflows/last-30-days-research/package.json @@ -20,7 +20,7 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/morning-brief/package.json b/workflows/morning-brief/package.json index f039381eb..ba56be3b8 100644 --- a/workflows/morning-brief/package.json +++ b/workflows/morning-brief/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/pain-point-collateral/package.json b/workflows/pain-point-collateral/package.json index 5036a4a16..b10b58814 100644 --- a/workflows/pain-point-collateral/package.json +++ b/workflows/pain-point-collateral/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/process-granola-call/package.json b/workflows/process-granola-call/package.json index f601b5e2e..9647be16e 100644 --- a/workflows/process-granola-call/package.json +++ b/workflows/process-granola-call/package.json @@ -20,8 +20,8 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/reddit-opportunity-scanner/package.json b/workflows/reddit-opportunity-scanner/package.json index 37870dad2..01a0bee8d 100644 --- a/workflows/reddit-opportunity-scanner/package.json +++ b/workflows/reddit-opportunity-scanner/package.json @@ -20,7 +20,7 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/workflows/workbench-digest/package.json b/workflows/workbench-digest/package.json index 92123ddd1..1c5fffc6a 100644 --- a/workflows/workbench-digest/package.json +++ b/workflows/workbench-digest/package.json @@ -20,7 +20,7 @@ "test": "bun test" }, "dependencies": { - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/workflow": "workspace:*" }, "devDependencies": {