From eed433dd6b8a34310ca29bba595be25172a73a71 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 13:53:13 +0100 Subject: [PATCH 1/4] Delete packages/core-agent, the old agent runtime nothing runs The VS Code extension runs labkit-effect's agent; it took only two logging helpers from core-agent (diagnosticError, redactDiagnostics), which move into app-vscode with their test. docs/agent/ was core-agent's guidance and goes too, except the note on what ACP's messages mean, now docs/acp-language.md. Co-Authored-By: Claude Opus 5.5 --- .dependency-cruiser-known-violations.json | 30 - .github/labeler.yml | 1 - CLAUDE.md | 4 +- bun.lock | 23 +- docs/{agent => }/acp-language.md | 0 docs/agent/agent-flow-diagrams.md | 119 - docs/agent/core-runtime.md | 75 - docs/agent/glossary.md | 152 - docs/agent/guidance.md | 206 - docs/agent/session-model.md | 187 - docs/agent/session-persistence.md | 43 - docs/agent/session-runtime.md | 289 - package.json | 2 - packages/app-vscode/package.json | 1 - .../src/handlers/FileSystemHandler.ts | 2 +- .../src/handlers/PermissionHandler.ts | 2 +- .../src/handlers/TerminalHandler.ts | 2 +- packages/app-vscode/src/utils/Logger.ts | 2 +- .../app-vscode/src/utils/file-diagnostics.ts | 2 +- packages/app-vscode/src/utils/redact.test.ts | 34 + .../src/utils/redact.ts} | 56 +- packages/core-agent/README.md | 52 - .../agent/agent-conversation.test.ts | 80 - .../core-agent/agent/agent-conversation.ts | 200 - packages/core-agent/agent/agent-fsm.test.ts | 91 - packages/core-agent/agent/agent-fsm.ts | 366 - .../core-agent/agent/agent-projection.test.ts | 84 - .../core-agent/agent/agent-runtime.test.ts | 429 - packages/core-agent/agent/agent-runtime.ts | 286 - packages/core-agent/agent/agent.test.ts | 122 - packages/core-agent/agent/agent.ts | 164 - packages/core-agent/agent/content.ts | 163 - .../core-agent/agent/domain-types.test.ts | 66 - .../core-agent/agent/operation-actor.test.ts | 119 - packages/core-agent/agent/operation-actor.ts | 218 - packages/core-agent/agent/permissions.ts | 72 - packages/core-agent/agent/prompt.ts | 257 - packages/core-agent/agent/session.test.ts | 338 - packages/core-agent/agent/test-support.ts | 26 - packages/core-agent/agent/tool-batch.test.ts | 67 - packages/core-agent/agent/tool-batch.ts | 171 - packages/core-agent/agent/types.ts | 402 - packages/core-agent/effects/README.md | 57 - packages/core-agent/effects/effects.test.ts | 299 - packages/core-agent/effects/index.ts | 869 - packages/core-agent/environment/README.md | 76 - .../environment/environment.test.ts | 70 - .../core-agent/environment/environment.ts | 83 - .../core-agent/environment/http-trace.test.ts | 93 - packages/core-agent/environment/index.ts | 2 - .../environment/provider-capture.test.ts | 105 - .../environment/provider-capture.ts | 248 - .../environment/testing/capture-process.ts | 57 - packages/core-agent/fsm/fsm.test.ts | 85 - packages/core-agent/fsm/fsm.ts | 115 - packages/core-agent/fsm/index.ts | 1 - packages/core-agent/host/README.md | 174 - packages/core-agent/host/commands/index.ts | 46 - packages/core-agent/host/commands/model.ts | 229 - .../core-agent/host/commands/permission.ts | 239 - packages/core-agent/host/commands/tools.ts | 326 - packages/core-agent/host/context.ts | 288 - packages/core-agent/host/host.test.ts | 194 - packages/core-agent/host/host.ts | 329 - packages/core-agent/host/index.ts | 2 - packages/core-agent/host/notifications.ts | 14 - packages/core-agent/host/ports.ts | 385 - .../host/testing/completion-contract.ts | 77 - .../core-agent/host/testing/tool-contract.ts | 76 - packages/core-agent/host/tool-updates.test.ts | 231 - packages/core-agent/index.ts | 2 - packages/core-agent/logging/README.md | 239 - .../logging/fixture-capture.test.ts | 107 - .../core-agent/logging/fixture-capture.ts | 82 - packages/core-agent/logging/logging.test.ts | 282 - .../core-agent/logging/test-capture.test.ts | 80 - packages/core-agent/package.json | 56 - packages/core-agent/policy/README.md | 117 - packages/core-agent/policy/index.ts | 1 - packages/core-agent/policy/policy.test.ts | 164 - packages/core-agent/policy/policy.ts | 494 - packages/core-agent/prettier.config.mjs | 15 - packages/core-agent/providers/README.md | 274 - .../providers/anthropic-adaptive.test.ts | 101 - .../providers/anthropic-messages-v2.ts | 174 - .../providers/anthropic-messages.ts | 100 - .../providers/anthropic-stop.test.ts | 93 - .../core-agent/providers/anthropic-stop.ts | 31 - .../core-agent/providers/attachments.test.ts | 251 - packages/core-agent/providers/catalog.test.ts | 123 - packages/core-agent/providers/catalog.ts | 294 - .../providers/google-generate-v2.ts | 178 - .../core-agent/providers/google-generate.ts | 148 - packages/core-agent/providers/index.ts | 27 - packages/core-agent/providers/models.dev.json | 1177 - packages/core-agent/providers/openai-chat.ts | 101 - .../providers/openai-responses-v2.ts | 136 - .../core-agent/providers/openai-responses.ts | 98 - packages/core-agent/providers/parity.test.ts | 268 - .../core-agent/providers/profiles.test.ts | 214 - packages/core-agent/providers/shared.ts | 130 - packages/core-agent/providers/stops.test.ts | 75 - packages/core-agent/providers/stops.ts | 46 - .../core-agent/providers/stream-assemblers.ts | 331 - packages/core-agent/providers/stream.ts | 173 - .../providers/streaming-profiles.ts | 88 - .../core-agent/providers/streaming.test.ts | 381 - .../providers/testing/profile-contract.ts | 83 - .../providers/testing/stream-vectors.ts | 198 - .../core-agent/providers/thinking.test.ts | 279 - .../providers/token-settings.test.ts | 192 - .../core-agent/providers/transport.test.ts | 652 - packages/core-agent/providers/transport.ts | 652 - packages/core-agent/providers/types.ts | 249 - packages/core-agent/providers/usage.test.ts | 138 - packages/core-agent/providers/usage.ts | 105 - packages/core-agent/session/README.md | 349 - .../session/attachments-runtime.test.ts | 428 - packages/core-agent/session/blobs.ts | 126 - .../session/consumer-contract.test.ts | 917 - .../session/continuation-blobs.test.ts | 282 - .../core-agent/session/domain-types.test.ts | 44 - packages/core-agent/session/events.ts | 45 - .../session/examples/completion-binding.ts | 57 - .../session/fixture-reporting.test.ts | 163 - packages/core-agent/session/fixture-runner.ts | 369 - .../core-agent/session/fixtures/README.md | 81 - .../core-agent/session/fixtures/branching.ts | 412 - .../session/fixtures/conversation.ts | 528 - .../session/fixtures/expected-v2.json | 25368 ---------------- .../core-agent/session/fixtures/expected.json | 10163 ------- packages/core-agent/session/fixtures/index.ts | 96 - .../session/fixtures/persistence.ts | 580 - .../core-agent/session/fixtures/providers.ts | 771 - .../core-agent/session/fixtures/support.ts | 257 - packages/core-agent/session/fixtures/tools.ts | 553 - packages/core-agent/session/fixtures/usage.ts | 184 - .../session/fixtures/wire-responses.ts | 386 - packages/core-agent/session/index.ts | 30 - .../session/journal-integrity.test.ts | 460 - packages/core-agent/session/journal/codec.ts | 96 - .../session/journal/domain-event.ts | 73 - .../session/journal/reduce-boundary.ts | 126 - .../session/journal/reduce-event.ts | 103 - .../session/journal/reduce-queue.ts | 104 - .../session/journal/reduce-tools.ts | 65 - packages/core-agent/session/journal/reduce.ts | 37 - packages/core-agent/session/journal/render.ts | 185 - packages/core-agent/session/journal/replay.ts | 227 - packages/core-agent/session/journal/seed.ts | 98 - packages/core-agent/session/journal/shared.ts | 123 - packages/core-agent/session/journal/stage.ts | 164 - packages/core-agent/session/journal/state.ts | 89 - .../core-agent/session/observability.test.ts | 485 - .../session/peer-review-acceptance.test.ts | 302 - .../session/permissions-runtime.test.ts | 761 - .../core-agent/session/persistence.test.ts | 39 - packages/core-agent/session/persistence.ts | 165 - .../core-agent/session/policy-runtime.test.ts | 831 - .../session/provider-runtime.test.ts | 386 - .../session/registry-adoption.test.ts | 487 - .../session/runtime/bind-options.ts | 48 - .../core-agent/session/runtime/configure.ts | 84 - .../session/runtime/conversation-effects.ts | 91 - .../core-agent/session/runtime/env-events.ts | 176 - packages/core-agent/session/runtime/facade.ts | 85 - .../core-agent/session/runtime/instance.ts | 340 - packages/core-agent/session/runtime/open.ts | 213 - .../session/runtime/registry-adoption.ts | 323 - .../session/runtime/session-commands.ts | 379 - .../session/session-fixtures.test.ts | 100 - .../core-agent/session/session-fsm.test.ts | 373 - packages/core-agent/session/session-fsm.ts | 746 - .../core-agent/session/session-log.test.ts | 30 - packages/core-agent/session/session-log.ts | 75 - .../session/session-operation.test.ts | 78 - .../core-agent/session/session-operation.ts | 220 - .../core-agent/session/session-prompt.test.ts | 95 - packages/core-agent/session/session-prompt.ts | 40 - .../session/session-runtime.test.ts | 636 - .../core-agent/session/session-runtime.ts | 292 - .../session/settlement-failure.test.ts | 132 - .../session/streaming-runtime.test.ts | 331 - packages/core-agent/session/test-support.ts | 100 - .../session/testing/memory-persistence.ts | 107 - .../session/testing/persistence-contract.ts | 209 - .../session/thinking-runtime.test.ts | 265 - .../core-agent/session/tool-updates.test.ts | 148 - packages/core-agent/session/types.ts | 383 - packages/core-agent/session/views.test.ts | 131 - packages/core-agent/session/views.ts | 111 - scripts/checks/check-comment-length.ts | 2 +- 192 files changed, 49 insertions(+), 72863 deletions(-) rename docs/{agent => }/acp-language.md (100%) delete mode 100644 docs/agent/agent-flow-diagrams.md delete mode 100644 docs/agent/core-runtime.md delete mode 100644 docs/agent/glossary.md delete mode 100644 docs/agent/guidance.md delete mode 100644 docs/agent/session-model.md delete mode 100644 docs/agent/session-persistence.md delete mode 100644 docs/agent/session-runtime.md create mode 100644 packages/app-vscode/src/utils/redact.test.ts rename packages/{core-agent/logging/index.ts => app-vscode/src/utils/redact.ts} (59%) delete mode 100644 packages/core-agent/README.md delete mode 100644 packages/core-agent/agent/agent-conversation.test.ts delete mode 100644 packages/core-agent/agent/agent-conversation.ts delete mode 100644 packages/core-agent/agent/agent-fsm.test.ts delete mode 100644 packages/core-agent/agent/agent-fsm.ts delete mode 100644 packages/core-agent/agent/agent-projection.test.ts delete mode 100644 packages/core-agent/agent/agent-runtime.test.ts delete mode 100644 packages/core-agent/agent/agent-runtime.ts delete mode 100644 packages/core-agent/agent/agent.test.ts delete mode 100644 packages/core-agent/agent/agent.ts delete mode 100644 packages/core-agent/agent/content.ts delete mode 100644 packages/core-agent/agent/domain-types.test.ts delete mode 100644 packages/core-agent/agent/operation-actor.test.ts delete mode 100644 packages/core-agent/agent/operation-actor.ts delete mode 100644 packages/core-agent/agent/permissions.ts delete mode 100644 packages/core-agent/agent/prompt.ts delete mode 100644 packages/core-agent/agent/session.test.ts delete mode 100644 packages/core-agent/agent/test-support.ts delete mode 100644 packages/core-agent/agent/tool-batch.test.ts delete mode 100644 packages/core-agent/agent/tool-batch.ts delete mode 100644 packages/core-agent/agent/types.ts delete mode 100644 packages/core-agent/effects/README.md delete mode 100644 packages/core-agent/effects/effects.test.ts delete mode 100644 packages/core-agent/effects/index.ts delete mode 100644 packages/core-agent/environment/README.md delete mode 100644 packages/core-agent/environment/environment.test.ts delete mode 100644 packages/core-agent/environment/environment.ts delete mode 100644 packages/core-agent/environment/http-trace.test.ts delete mode 100644 packages/core-agent/environment/index.ts delete mode 100644 packages/core-agent/environment/provider-capture.test.ts delete mode 100644 packages/core-agent/environment/provider-capture.ts delete mode 100644 packages/core-agent/environment/testing/capture-process.ts delete mode 100644 packages/core-agent/fsm/fsm.test.ts delete mode 100644 packages/core-agent/fsm/fsm.ts delete mode 100644 packages/core-agent/fsm/index.ts delete mode 100644 packages/core-agent/host/README.md delete mode 100644 packages/core-agent/host/commands/index.ts delete mode 100644 packages/core-agent/host/commands/model.ts delete mode 100644 packages/core-agent/host/commands/permission.ts delete mode 100644 packages/core-agent/host/commands/tools.ts delete mode 100644 packages/core-agent/host/context.ts delete mode 100644 packages/core-agent/host/host.test.ts delete mode 100644 packages/core-agent/host/host.ts delete mode 100644 packages/core-agent/host/index.ts delete mode 100644 packages/core-agent/host/notifications.ts delete mode 100644 packages/core-agent/host/ports.ts delete mode 100644 packages/core-agent/host/testing/completion-contract.ts delete mode 100644 packages/core-agent/host/testing/tool-contract.ts delete mode 100644 packages/core-agent/host/tool-updates.test.ts delete mode 100644 packages/core-agent/index.ts delete mode 100644 packages/core-agent/logging/README.md delete mode 100644 packages/core-agent/logging/fixture-capture.test.ts delete mode 100644 packages/core-agent/logging/fixture-capture.ts delete mode 100644 packages/core-agent/logging/logging.test.ts delete mode 100644 packages/core-agent/logging/test-capture.test.ts delete mode 100644 packages/core-agent/package.json delete mode 100644 packages/core-agent/policy/README.md delete mode 100644 packages/core-agent/policy/index.ts delete mode 100644 packages/core-agent/policy/policy.test.ts delete mode 100644 packages/core-agent/policy/policy.ts delete mode 100644 packages/core-agent/prettier.config.mjs delete mode 100644 packages/core-agent/providers/README.md delete mode 100644 packages/core-agent/providers/anthropic-adaptive.test.ts delete mode 100644 packages/core-agent/providers/anthropic-messages-v2.ts delete mode 100644 packages/core-agent/providers/anthropic-messages.ts delete mode 100644 packages/core-agent/providers/anthropic-stop.test.ts delete mode 100644 packages/core-agent/providers/anthropic-stop.ts delete mode 100644 packages/core-agent/providers/attachments.test.ts delete mode 100644 packages/core-agent/providers/catalog.test.ts delete mode 100644 packages/core-agent/providers/catalog.ts delete mode 100644 packages/core-agent/providers/google-generate-v2.ts delete mode 100644 packages/core-agent/providers/google-generate.ts delete mode 100644 packages/core-agent/providers/index.ts delete mode 100644 packages/core-agent/providers/models.dev.json delete mode 100644 packages/core-agent/providers/openai-chat.ts delete mode 100644 packages/core-agent/providers/openai-responses-v2.ts delete mode 100644 packages/core-agent/providers/openai-responses.ts delete mode 100644 packages/core-agent/providers/parity.test.ts delete mode 100644 packages/core-agent/providers/profiles.test.ts delete mode 100644 packages/core-agent/providers/shared.ts delete mode 100644 packages/core-agent/providers/stops.test.ts delete mode 100644 packages/core-agent/providers/stops.ts delete mode 100644 packages/core-agent/providers/stream-assemblers.ts delete mode 100644 packages/core-agent/providers/stream.ts delete mode 100644 packages/core-agent/providers/streaming-profiles.ts delete mode 100644 packages/core-agent/providers/streaming.test.ts delete mode 100644 packages/core-agent/providers/testing/profile-contract.ts delete mode 100644 packages/core-agent/providers/testing/stream-vectors.ts delete mode 100644 packages/core-agent/providers/thinking.test.ts delete mode 100644 packages/core-agent/providers/token-settings.test.ts delete mode 100644 packages/core-agent/providers/transport.test.ts delete mode 100644 packages/core-agent/providers/transport.ts delete mode 100644 packages/core-agent/providers/types.ts delete mode 100644 packages/core-agent/providers/usage.test.ts delete mode 100644 packages/core-agent/providers/usage.ts delete mode 100644 packages/core-agent/session/README.md delete mode 100644 packages/core-agent/session/attachments-runtime.test.ts delete mode 100644 packages/core-agent/session/blobs.ts delete mode 100644 packages/core-agent/session/consumer-contract.test.ts delete mode 100644 packages/core-agent/session/continuation-blobs.test.ts delete mode 100644 packages/core-agent/session/domain-types.test.ts delete mode 100644 packages/core-agent/session/events.ts delete mode 100644 packages/core-agent/session/examples/completion-binding.ts delete mode 100644 packages/core-agent/session/fixture-reporting.test.ts delete mode 100644 packages/core-agent/session/fixture-runner.ts delete mode 100644 packages/core-agent/session/fixtures/README.md delete mode 100644 packages/core-agent/session/fixtures/branching.ts delete mode 100644 packages/core-agent/session/fixtures/conversation.ts delete mode 100644 packages/core-agent/session/fixtures/expected-v2.json delete mode 100644 packages/core-agent/session/fixtures/expected.json delete mode 100644 packages/core-agent/session/fixtures/index.ts delete mode 100644 packages/core-agent/session/fixtures/persistence.ts delete mode 100644 packages/core-agent/session/fixtures/providers.ts delete mode 100644 packages/core-agent/session/fixtures/support.ts delete mode 100644 packages/core-agent/session/fixtures/tools.ts delete mode 100644 packages/core-agent/session/fixtures/usage.ts delete mode 100644 packages/core-agent/session/fixtures/wire-responses.ts delete mode 100644 packages/core-agent/session/index.ts delete mode 100644 packages/core-agent/session/journal-integrity.test.ts delete mode 100644 packages/core-agent/session/journal/codec.ts delete mode 100644 packages/core-agent/session/journal/domain-event.ts delete mode 100644 packages/core-agent/session/journal/reduce-boundary.ts delete mode 100644 packages/core-agent/session/journal/reduce-event.ts delete mode 100644 packages/core-agent/session/journal/reduce-queue.ts delete mode 100644 packages/core-agent/session/journal/reduce-tools.ts delete mode 100644 packages/core-agent/session/journal/reduce.ts delete mode 100644 packages/core-agent/session/journal/render.ts delete mode 100644 packages/core-agent/session/journal/replay.ts delete mode 100644 packages/core-agent/session/journal/seed.ts delete mode 100644 packages/core-agent/session/journal/shared.ts delete mode 100644 packages/core-agent/session/journal/stage.ts delete mode 100644 packages/core-agent/session/journal/state.ts delete mode 100644 packages/core-agent/session/observability.test.ts delete mode 100644 packages/core-agent/session/peer-review-acceptance.test.ts delete mode 100644 packages/core-agent/session/permissions-runtime.test.ts delete mode 100644 packages/core-agent/session/persistence.test.ts delete mode 100644 packages/core-agent/session/persistence.ts delete mode 100644 packages/core-agent/session/policy-runtime.test.ts delete mode 100644 packages/core-agent/session/provider-runtime.test.ts delete mode 100644 packages/core-agent/session/registry-adoption.test.ts delete mode 100644 packages/core-agent/session/runtime/bind-options.ts delete mode 100644 packages/core-agent/session/runtime/configure.ts delete mode 100644 packages/core-agent/session/runtime/conversation-effects.ts delete mode 100644 packages/core-agent/session/runtime/env-events.ts delete mode 100644 packages/core-agent/session/runtime/facade.ts delete mode 100644 packages/core-agent/session/runtime/instance.ts delete mode 100644 packages/core-agent/session/runtime/open.ts delete mode 100644 packages/core-agent/session/runtime/registry-adoption.ts delete mode 100644 packages/core-agent/session/runtime/session-commands.ts delete mode 100644 packages/core-agent/session/session-fixtures.test.ts delete mode 100644 packages/core-agent/session/session-fsm.test.ts delete mode 100644 packages/core-agent/session/session-fsm.ts delete mode 100644 packages/core-agent/session/session-log.test.ts delete mode 100644 packages/core-agent/session/session-log.ts delete mode 100644 packages/core-agent/session/session-operation.test.ts delete mode 100644 packages/core-agent/session/session-operation.ts delete mode 100644 packages/core-agent/session/session-prompt.test.ts delete mode 100644 packages/core-agent/session/session-prompt.ts delete mode 100644 packages/core-agent/session/session-runtime.test.ts delete mode 100644 packages/core-agent/session/session-runtime.ts delete mode 100644 packages/core-agent/session/settlement-failure.test.ts delete mode 100644 packages/core-agent/session/streaming-runtime.test.ts delete mode 100644 packages/core-agent/session/test-support.ts delete mode 100644 packages/core-agent/session/testing/memory-persistence.ts delete mode 100644 packages/core-agent/session/testing/persistence-contract.ts delete mode 100644 packages/core-agent/session/thinking-runtime.test.ts delete mode 100644 packages/core-agent/session/tool-updates.test.ts delete mode 100644 packages/core-agent/session/types.ts delete mode 100644 packages/core-agent/session/views.test.ts delete mode 100644 packages/core-agent/session/views.ts diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json index 387c91ef..bfd28946 100644 --- a/.dependency-cruiser-known-violations.json +++ b/.dependency-cruiser-known-violations.json @@ -12,35 +12,5 @@ "severity": "error", "name": "core-knows-no-verbs" } - }, - { - "type": "cycle", - "from": "packages/core-agent/session/runtime/env-events.ts", - "to": "packages/core-agent/session/runtime/instance.ts", - "unresolvedTo": "./instance.ts", - "dependencyTypes": [ - "local", - "import" - ], - "rule": { - "severity": "error", - "name": "no-circular" - }, - "cycle": [ - { - "name": "packages/core-agent/session/runtime/instance.ts", - "dependencyTypes": [ - "local", - "import" - ] - }, - { - "name": "packages/core-agent/session/runtime/env-events.ts", - "dependencyTypes": [ - "local", - "import" - ] - } - ] } ] diff --git a/.github/labeler.yml b/.github/labeler.yml index 4bc09a8e..f9e5e434 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -23,7 +23,6 @@ "area:agent": - changed-files: - any-glob-to-any-file: - - packages/core-agent/** - packages/app-vscode/** - packages/acp-fake/** - packages/acp-scenarios/** diff --git a/CLAUDE.md b/CLAUDE.md index 6ee25b17..00e3a42e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,13 +60,11 @@ packages/core-db/ nodes and edges. All graph access goes through TenantGra packages/core-domain/ research actions. Verb-first: no createClaim(), only recordAnalysis(). packages/app-mcp/ the agent surface. packages/app-cli/ the terminal surface. A composition root, nothing else. -packages/core-agent/ durable, inspectable agent sessions: journal, projections, providers, effects. ``` The dev stack runs labkit-effect's ACP agent behind the HTTP bridge `labkit-agent-http.ts` in `packages/app-web/scripts/`; the VS Code extension runs whichever agent its settings name. -`packages/core-agent` has its own guidance in `docs/agent/`; start at `docs/agent/guidance.md`. -`bun run test:agent` runs its tests. +`docs/acp-language.md` says what ACP's messages mean and oblige. `packages/core-db/domain.ts` is the domain as graph structure — labels, edges, property shapes. `packages/core-domain/` is the domain as it matters to a researcher. diff --git a/bun.lock b/bun.lock index 1dadd02c..c5a2c76f 100644 --- a/bun.lock +++ b/bun.lock @@ -103,7 +103,6 @@ "version": "0.1.0", "dependencies": { "@agentclientprotocol/sdk": "catalog:", - "@labkit/core-agent": "workspace:*", "@labkit/ui": "workspace:*", "@labkit/view-model": "workspace:*", "react": "catalog:", @@ -161,18 +160,6 @@ "vite": "^7.1.5", }, }, - "packages/core-agent": { - "name": "@labkit/core-agent", - "version": "0.1.0", - "dependencies": { - "@logtape/logtape": "catalog:", - "zod": "catalog:", - }, - "devDependencies": { - "@logtape/testing-bun": "catalog:", - "prettier": "^3.8.3", - }, - }, "packages/core-db": { "name": "@labkit/core-db", "version": "0.0.0", @@ -454,8 +441,6 @@ "@labkit/app-web": ["@labkit/app-web@workspace:packages/app-web"], - "@labkit/core-agent": ["@labkit/core-agent@workspace:packages/core-agent"], - "@labkit/core-db": ["@labkit/core-db@workspace:packages/core-db"], "@labkit/core-domain": ["@labkit/core-domain@workspace:packages/core-domain"], @@ -470,10 +455,6 @@ "@logtape/logtape": ["@logtape/logtape@2.4.0", "", {}, "sha512-xhvG/NwfBXs+a+oJxl6xOmqA4zX8giP1brvd5lVbL3qUHUkZfVt+4QE/+9p/Vf76JNwe/upH3XYTNItocKQ8Qg=="], - "@logtape/testing": ["@logtape/testing@2.3.8", "", { "peerDependencies": { "@logtape/logtape": "^2.3.8" } }, "sha512-KWoBfhJ4w/9Bc68q6PsUA9NHqWVGkG919OoTivgfalXSuf5xl346eY7Yr5rN3hu7lm+7kHgYu6lbrolEsPY62A=="], - - "@logtape/testing-bun": ["@logtape/testing-bun@2.3.8", "", { "dependencies": { "@logtape/testing": "^2.3.8" }, "peerDependencies": { "@logtape/logtape": "^2.3.8" } }, "sha512-7QW/qfX2gkeb22TmBYxJqNBqgsADHoFI0jOi/fLeiSv/bkAo9FA6kL4cTO9GPJh4W3WFbE4XyA2pY9jSiEJrhQ=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA=="], "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], @@ -1752,6 +1733,8 @@ "@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=="], + "@labkit/app-vscode/@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -1824,6 +1807,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@labkit/app-vscode/@types/bun/bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], diff --git a/docs/agent/acp-language.md b/docs/acp-language.md similarity index 100% rename from docs/agent/acp-language.md rename to docs/acp-language.md diff --git a/docs/agent/agent-flow-diagrams.md b/docs/agent/agent-flow-diagrams.md deleted file mode 100644 index e8e73712..00000000 --- a/docs/agent/agent-flow-diagrams.md +++ /dev/null @@ -1,119 +0,0 @@ -# Operation identity and cancellation - -These diagrams explain actor behavior shared by both runtimes. Actor commit installs immutable -state; it does **not** mean journal persistence. The session adds the -[receipt gates](session-runtime.md) before commands execute. Dashed returns below are outcomes, -not permission to bypass those gates. - -## A turn has a permission phase - -```mermaid -stateDiagram-v2 - [*] --> idle - idle --> awaiting_model: user, steps remain / consume step - idle --> done: user, no steps / exhausted - idle --> done: abort - awaiting_model --> done: answer - awaiting_model --> awaiting_permission: tools, permissions ask - awaiting_model --> executing_tools: tools, permissions off - awaiting_permission --> executing_tools: every call approved or refused - awaiting_permission --> done: failure or cancellation - awaiting_model --> awaiting_model: handoff, steps remain / successor's step - awaiting_model --> done: handoff, no steps / exhausted - executing_tools --> awaiting_model: batch success, steps remain / consume step - executing_tools --> done: batch success, no steps / exhausted - executing_tools --> done: batch failure or cancellation - executing_tools --> cancelling_tools: abort - cancelling_tools --> done: batch outcome, preserve accepted results - awaiting_model --> done: projection or completion failure, or cancellation - done --> idle: record terminal turn and reset allowance -``` - -`awaiting_model` is one host operation: it projects the step's prompt from the journaled facts, then -calls the model. A handoff sets the turn's view to a marker naming the predecessor and the message -index where the handoff happened; the successor's step renders the handoff packet from it. Neither -the prompt nor the packet is journaled. -Replacement input is omitted from this diagram for readability; the sequence below covers it. -A refused call does not stop the turn: the batch still runs, answering that call with a -permission-refused tool result the model reads on its next step. Permission-dialog cancellation -aborts the turn; an invalid response or port failure fails it. The distinction must survive into a -client-facing result. - -## Replace model work without accepting its late answer - -```mermaid -sequenceDiagram - participant A as Application - participant C as Conversation - participant H as Shared host - participant O as Old operation - participant N as New operation - A->>C: replacement user input - C->>C: install new child identity in the same turn - C->>H: cancel old child; start replacement - H->>O: abort signal - H->>N: start - N-->>H: result for new child - H-->>C: correlated outcome - C->>C: accept matching turn and child - O-->>H: late result, if adapter ignored signal - H->>H: ignore already-cancelled operation result - Note over C,H: Conversation also rejects mismatched turn or child identities. -``` - -Both inputs settle with the same terminal turn. Under the default policy, replacement applies to a -step in flight (including a handoff successor's first step), not tool or permission work. A timeout uses the same cancellation -machinery with a distinct typed reason; it does not spawn a replacement automatically. - -## Cancelling a batch preserves what was accepted - -```mermaid -sequenceDiagram - participant A as Application - participant C as Conversation - participant H as Host - participant B as Tool batch - participant T as Tools A and B - H->>T: start both calls - T-->>H: A succeeds - Note over H,B: Session saves A's result before release.
In-memory runtime releases immediately. - H->>B: accept A result - A->>C: abort - C->>C: enter cancelling_tools - C->>H: cancel batch - H->>B: cancel - B->>H: cancel unfinished B - H->>T: abort B signal - B-->>C: cancelled batch with A result, through host - C->>C: record aborted turn with accepted result -``` - -Tool B may already have changed an external service. Its cancellation is not proof that nothing -happened. On failure, unfinished siblings receive the initiating call's cause so the public outcome -and logs can explain why the rest of the batch stopped. - -## An operation settles once - -```mermaid -stateDiagram-v2 - [*] --> ready - ready --> validating_input: start - validating_input --> running: valid input - running --> validating_output: returned - validating_output --> succeeded: valid output - validating_input --> failed: invalid input - running --> failed: execution failure - validating_output --> failed: invalid output - ready --> cancelled: cancel - validating_input --> cancelled: cancel - running --> cancelled: cancel - validating_output --> cancelled: cancel - succeeded --> [*] - failed --> [*] - cancelled --> [*] -``` - -Cancellation carries a reason. The surrounding host/turn translates a timeout reason into a failed -terminal outcome with classification `timeout`; user cancellation remains distinguishable. A -terminal actor does not accept a late success. Keep arbitrary Error objects and AbortControllers -outside snapshots; retain serializable causes and operation identity inside outcomes. diff --git a/docs/agent/core-runtime.md b/docs/agent/core-runtime.md deleted file mode 100644 index 61449547..00000000 --- a/docs/agent/core-runtime.md +++ /dev/null @@ -1,75 +0,0 @@ -# Why the runtime uses actors - -An external operation can finish after the caller has cancelled it, replaced it, or closed the -session. The runtime must decide whether that outcome still belongs to current work. Serialized -actor mailboxes and explicit operation identities provide that decision boundary. - -Use the [session API](../../packages/core-agent/session/README.md) for durable integrations. The `/agent` -runtime uses the same host but keeps history in memory. Its event-processing acknowledgement does -not promise that a turn has finished, and it has no crash-recovery guarantee. - -## Commit a decision before starting its effects - -A decision returns the complete next state and commands synchronously. The mailbox freezes that -state before dispatching commands. If a fast adapter reports back immediately, it therefore sees -the operation that the decision actually installed. Mutating state later or starting I/O inside a -decision would break this ordering and make replay depend on timing. - -Runtime authors should keep controllers, callbacks, credentials, and resource handles in the host. -Snapshots contain immutable data; holding an old snapshot remains safe while the runtime advances. -Session commit additionally means a validated storage receipt. In-memory actor commit only means -that the next state is installed; the two guarantees are different. - -## Correlate work by identity, not by timing - -Every child outcome carries its turn and operation identity. The active state names the child it -will accept. Barge-in during model work installs a replacement child before cancelling the old one. -A late response from the old child cannot become the replacement's answer, even if its adapter -ignored cancellation. Do not bypass this check by routing completion callbacks directly into a UI's -conversation history. - -User input cannot replace tool execution under the default policy: a tool may already have changed -the outside world. Explicit abort stops the batch locally and preserves accepted partial results. -The session policy layer can queue successor input or request tool cancellation before starting it. -[Actor diagrams](agent-flow-diagrams.md) illustrate the distinction. - -## Validate at the point where trust changes - -Model output is untrusted even if its JSON parses. Completion admission checks tool names, handoff -targets, and call correlation before creating tool work. Each tool then validates arguments and -output. An output-validation failure can occur after an external effect, so it must carry the same -identity and diagnostic cause as execution failures. - -A batch owns call membership and partial results. It ignores duplicate/unknown outcomes; successful -settlement requires every original call exactly once. The turn owns only the batch outcome. This -keeps parallel-call bookkeeping out of turn decisions and gives cancellation one place to preserve -results. Sessions additionally commit individual results before releasing them to the batch. - -## Preserve the difference between history and a model request - -Stored history records what occurred. Projection chooses what a model needs now. In an interrupted -historical tool exchange, the default projection can omit calls without results so the next request -is valid; it does not rewrite the stored evidence. Missing results in a completed exchange are an -error rather than context to silently discard. - -A step is one model attempt: its prompt is projected and its model called as a single operation, -including replacement/handoff/tool-loop continuation. A step counts when it starts, so a barge-in -that replaces an in-flight step uses another, and a handoff's successor starts its own step. The -prompt is recomputed from the journaled facts each time and never journaled. Media the target -cannot, or might not, read becomes a pointer; missing or corrupt attachment bytes, or an invalid -projected exchange, fail the step before any HTTP request. -Zero allowance exhausts the turn without dispatch. -This bounds a loop, not tokens, elapsed time, or external effects. Model settings, projection, and -input admission are described in the [policy guide](../../packages/core-agent/policy/README.md). - -## Fork at a completed boundary - -A fork waits for the active turn to end, captures that exact boundary, and receives independent -execution resources. Reading the parent's snapshot later would let a subsequent input leak into -the child. Compaction is the same boundary with caller-supplied replacement context, not an -implicit model summary. For persisted publication and interrupted creation, use the -[session diagrams](session-runtime.md#branch-publication-has-two-persistence-boundaries). - -When changing this machinery, test immediate replies, late replies after cancellation, duplicate -call IDs, partial tool results, and a queued event at the fork boundary. Happy-path transcripts -alone cannot establish these ordering guarantees. diff --git a/docs/agent/glossary.md b/docs/agent/glossary.md deleted file mode 100644 index 417c4add..00000000 --- a/docs/agent/glossary.md +++ /dev/null @@ -1,152 +0,0 @@ -# Glossary - -These are the canonical meanings of Labkit's domain terms. Use them in code, docs, plans and agent -prompts. When existing code uses a word differently, the "Code today" note says so. Those uses are -naming debt, not alternative definitions. - -## Conversation structure - -**Turn.** Everything from a user prompt until the model stops. After that the conversation waits for -the next prompt. One turn can contain hundreds of steps and last an hour. -Code today: `TurnState` (`packages/core-agent/agent/agent-fsm.ts`). ACP calls a turn a "prompt turn": -one `session/prompt` request and everything up to its response. VS Code calls it a "prompt". -Not to be confused with the provider stop reason `end_turn`. - -**Step.** One LLM call. The model emits narrative and tool-call decisions. The harness dispatches those -tool calls and gathers their results, and the results go back to the model in the next step. -Code today: "step" in `docs/core-runtime.md` and the policy's `steps` allowance. `TurnData.generation` -is not a step counter; it counts child operations: one per step, plus one per permission request and -tool batch. - -**Step boundary.** The point where the next completion request is assembled from history, the current -turn and any new context. This is where interjections are inserted. -Code today: prompt projection inside the `complete` host operation, which projects the prompt with -the policy `project` resolver (such as `history@1`) and then makes the LLM call. The prompt is not -journaled. The provider profile's `encode` only formats the request; it adds no content. - -**Settled step.** A step whose model output is committed to the journal (`model_settled`). Queued -notices wait for this point. Other things also "settle" (operations, tool batches, turns, append -receipts); always name which one. - -**Tool call / tool batch.** A tool call is one invocation proposed by a step. The tool batch is all -calls proposed by one step. Code today: the next step waits until the whole batch has settled. That -is current behaviour, not a requirement (see Backgrounded tool call). - -## Getting context to the model mid-turn - -**Interjection.** Context that arrives while a turn is running and is delivered to the model at the -next step boundary. The turn continues. There are four kinds: - -- **Steering:** input from the user. -- **Advisory:** guidance from an advisor reviewing the work. -- **System notice:** code-based rules, TODO-list reminders, messages from other sessions or agents. -- **Memory recall:** memories surfaced by what the agent is doing, such as reading or editing a file. - -An interjection that has gone stale should be dropped or re-checked before delivery. A note is stale -when its subject changed after it was written. -Status: not implemented. There is no interjection event type. User input during tools is rejected -under `default@1`, and at best (`queue-user`) it waits until the turn ends. `system` events are -refused while a turn is active. - -**Backgrounded tool call.** A tool call that is still running when the next step starts. The model -receives a placeholder result ("running in the background") so that the interjection can be -delivered, and the real result arrives later as ordinary content. -Status: not implemented. The turn machine holds one child operation at a time and moves on only -after the whole batch settles. - -**Barge-in.** Cancelling a live completion, streaming or not, and re-issuing that step with the new -context. This is a separate feature from interjection at the step boundary. -Code today: the `bargeIn` policy flag; some docs call it "replacement". It is implemented only for -user input while a step is in flight (prompt projection and LLM call are one operation). The partial -stream is discarded, and the re-issued call uses up a step. - -**Queued input.** User input held for delivery after the current turn (`pendingInputs`, the -`queue-user` policy). It is not an interjection: it waits for the end of the turn, not for the next -step. - -## Configuration and history - -**Configuration.** The user-selectable settings of a session: provider, model, thinking, output limit, -streaming, permissions, tool scope and tool-failure handling. Changing it is two transitions: -selecting a value commits it to the session's configuration store immediately, at any time; applying -it records a "configuration applied" fact at the next boundary between turns. A turn already in -progress keeps the settings it started with. Users choose providers and models by name. Adapter -profile versions (such as `anthropic-messages@4`) are internal and are never offered as choices. -Code today: `updatePolicy(patch)` selects (the store is the persistence port's `getConfig`/`putConfig`) -and the "configuration applied" fact is a `policy` record holding the resulting settings. - -**Model catalog.** The list of providers and models the environment can use, together with each -model's thinking choices and output limit. It is built from the committed models.dev snapshot, plus a -localhost server if one is reachable (`packages/core-agent/providers/catalog.ts`). - -**Registry.** The live tool and agent definitions a session runs with. When a session is reopened, a -registry that differs from the saved one is adopted: the first new work records the live registry in -the journal, and the saved history stays unchanged. - -**History.** The committed record of what happened: prompts, model output, tool calls and results, -and configuration changes. History is a record of facts. Reopening a session must never require -today's models, tools or settings to match those of the past. - -**Load.** Rebuilding session state from the journal. This is a fold over committed records, checked -for journal integrity only: schema, batch and revision continuity, append and entry identifiers, -session identity, the creation record first, and terminal records where a turn ended. A record must -also name a turn, operation or queued input that exists in the folded state. Commit-time rules are -not re-run, and no projection is recomputed. -Code today: `replay()` in `session-log.ts`; a failure is a `JournalIntegrityError` naming the rule -and the offending record. No prompt is stored. A terminal record's agent and outcome are the -turn-ended fact and are taken as written; effect records have no fold precondition. A policy record -holds the settings a configuration applied, and load applies it as written. - -**Recovery.** Closing a turn that was interrupted by process exit when the session is reloaded. External -effects are not repeated. Do not confuse it with **reconciliation**, which resolves a storage append -whose outcome was uncertain. - -## Facts, projections and effects - -See [session model](session-model.md) for the full reference. - -**Fact.** Something that happened in the conversation, recorded once in the session journal: user -input, model output, a tool result, a permission decision, a configuration being applied, a turn -ending. The journal holds facts and nothing else. - -**Projection.** A view computed from facts, never stored as history: the next LLM prompt, a transcript, -ACP replay, `/export`. The prompt projection also takes the target model's capabilities, and renders -any part the target cannot read, or might not read, as a pointer. - -**Effect.** An interaction with the outside world: an HTTP request and response, token usage and -cost, raw tool output, a permission request. Effects are emitted as events where they happen and -consumed by logging, capture, accounting and hooks. The journal may record chosen effects (such as -per-step usage) as records typed separately from facts; the conversation fold ignores them. -Code today: host, tool, permission and provider effects are typed `EffectEvent`s emitted through a -bound emitter (`packages/core-agent/effects`); session lifecycle, journal appends and loads, and the ACP -adapter still log with direct `diagnostic()` calls. Token usage is an `effect` record staged after -the step's model output in the same append. - -**Decision point.** A named seam where a policy chooses what happens next, for example tool -permission, provider selection or a budget veto. A policy may read facts, configuration and data -gathered from effects. An outcome that changes the conversation is recorded as a fact. - -**Blob URI.** `blob://.`, the reference form of stored binary content. The harness resolves -it against whatever store holds the bytes; a model that reads it with a tool receives the content. - -**Session journal / session log.** The journal holds facts. "Session log" means the effect log. -Code today: `session-log.ts` is the journal module. - -## Words with more than one meaning in code today - -Qualify these words, or rename them in new code: - -- **admission:** a durable input receipt, the policy field for handling input during a turn, and - staging failures. Prefer "input receipt" and "mid-turn input policy". -- **batch:** a tool batch, and a journal append batch (`CommittedBatch`). -- **child:** a child operation of a turn, and a child session created by fork or compaction. -- **settle(d):** operation, tool batch, turn, and append receipt. -- **pending / queue:** durable queued inputs, the in-memory submissions awaiting a storage receipt, and - staged appends. -- **projection:** now one concept (any view computed from facts). Name which projection: the prompt - projection, the web console view, ACP replay. -- **continuation:** a provider continuation payload (thinking signatures), the next step of a turn, - and continuing after a tool error. -- **system:** standing session instructions (`systemInputs`), an agent's `systemPrompt`, and the - `system` chat role. None of these is a system notice. -- **replacement:** barge-in, compaction context, and registry replacement. diff --git a/docs/agent/guidance.md b/docs/agent/guidance.md deleted file mode 100644 index ab7dccab..00000000 --- a/docs/agent/guidance.md +++ /dev/null @@ -1,206 +0,0 @@ -# Agent runtime guidance - -Guidance for `packages/core-agent`: an experimental TypeScript runtime for durable, inspectable -agent sessions. The repository's own rules in -`CLAUDE.md` apply as well; where the two differ on formatting or checks, `CLAUDE.md` and the -repository's checks win. - -## Find the right documentation - -When a module has a README, read it before editing that module. Module READMEs own local -APIs, boundaries, invariants, and testing notes. Higher-level and cross-module -architecture belongs in `docs/agent/`. - -- `packages/core-agent/agent/`: pure turn and conversation decisions, prompt projection, - operation actors, and the nonjournaled runtime. -- `packages/core-agent/fsm/`: typed decisions, immutable snapshots, and serialized actor - mailboxes. -- `packages/core-agent/session/`: journaled execution, persistence receipts, settlement, - restore/recovery, and durable branches. See its `README.md`. -- `packages/core-agent/host/`: shared resource ownership, child routing, cancellation, and - completion/tool ports. See its `README.md`. -- `packages/core-agent/policy/`: versioned input, projection, handoff, tool-error, and provider - policies. See its `README.md`. -- `packages/core-agent/providers/`: pure versioned provider profiles and the shared fetch - transport. See its `README.md`. -- `packages/core-agent/environment/`: asynchronous event sources and renderer bindings. See - its `README.md`. -- `packages/core-agent/logging/`: environment-owned diagnostic logging. See its `README.md`. - -Read `docs/agent/glossary.md` before writing plans, docs or code that name runtime concepts -(turn, step, interjection, barge-in, configuration, load). Its definitions are canonical; code -that uses those words differently is naming debt. - -Read `docs/agent/session-model.md` before changing what the journal records, how prompts or views -are built, or where effects are logged. It defines facts, projections, effects and decision -points; where code or other docs disagree with it, the code is the divergence. - -Use `docs/agent/core-runtime.md` and `docs/agent/agent-flow-diagrams.md` for the underlying agent -runtime. Use `docs/agent/session-runtime.md` for persistence and session flows. -`docs/agent/acp-language.md` says what ACP's messages mean and oblige, as the specification -states them. - -When documentation and implementation differ, source and tests describe current -behavior. Update the relevant module README for a local contract change and the -appropriate file in `docs/agent/` for a cross-module architecture change. - -## Commands - -Run commands from the repository root: - -```sh -bun run test:agent # core-agent's tests -bun run test:agent:logs # the same with debug diagnostics printed -bun test path/to/file.test.ts # scope tests to a file -bun run typecheck -bun run check # everything, before committing -``` - -Use Bun rather than Node.js, npm, pnpm, or Yarn. Use `bun:test` for tests and prefer -Bun's built-in APIs where applicable. Bun loads environment files automatically; do -not add dotenv. - -For behavior changes, run focused tests, the relevant module suite, and type checking. -The codebase contains compile-time assertions that the test runner alone cannot -validate. Documentation-only changes need formatting and diff review rather than application tests. - -## Definition of done: observability and traceability - -A feature or behavior change is not done until it can be diagnosed from its runtime -logs. Passing functional tests, producing journal fixtures, or writing files under -`session_artefacts` / `.session-artifacts` does not satisfy this requirement. Users -must not need to copy protocol traffic from a UI or reproduce a failure under a -debugger to discover why an operation stopped. - -- Include observability in the feature's acceptance criteria and implementation, - not as follow-up work. Instrument meaningful lifecycle events: admission, dispatch, - waiting and its reason, state transitions, completion, failure, cancellation, and - recovery where applicable. Successful execution must also leave useful evidence. -- Make events traceable across boundaries. Use stable event names and structured - fields with the applicable session, turn, operation/child, tool call, request, and - append IDs. Include relevant provider/profile versions, configuration changes, - statuses, limits, and timing so an operator can reconstruct what happened. -- Preserve diagnostic causes through error translation. Record the actual provider - stop reason, HTTP status, provider request ID, error code, and useful error details - when available. Do not replace this evidence with only "Internal error", "Agent - turn failed", a list of allowed schema values, or an unexplained status code. - Distinguish exhausted limits, rejected permissions, cancellation, invalid input, - missing bindings, and transport/provider failures. -- Protect secrets through targeted field exclusion or redaction. Do not use - "security", "privacy", or "safe logging" as a reason to swallow exceptions, - discard causes, or remove the evidence needed to debug the system. Routine - diagnostics do not require prompts, file contents, credentials, or authorization - headers. Preserve useful error metadata and explanations without those payloads. -- Environment launchers must provide durable, bounded diagnostic output with a - documented location, levels, rotation/retention, and retrieval procedure. Stderr - or a transient UI Output channel alone is insufficient for a launched agent. - Keep ACP stdout exclusively for protocol traffic. Core must not configure global - logging or own file sinks; log at runtime/adapter boundaries, keeping decisions pure. -- Add tests that assert the feature's expected diagnostic events and correlation - fields on successful and relevant failed/cancelled paths. Exercise the real - instrumentation, not test-only print statements. Verify sensitive fields are - excluded without erasing diagnostic causes. -- Run the affected tests with debug logging enabled and inspect the emitted output: - - ```sh - LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun test path/to/affected.test.ts - ``` - - Check that the new behavior produces the expected new events or meaningful fields - in existing events. Unchanged log output across newly implemented lifecycle paths - is a gap to investigate, not evidence of success. Test capture must show the logs - that runtime instrumentation emits when the feature works and when it fails. - -Completion reports must state what diagnostic evidence was verified and where an -operator can retrieve it. If instrumentation or durable launcher logging is missing, -report the work as incomplete; do not claim that passing tests makes it done. - -## Meaningful Failure Reports - -- Make failure reports self-contained. State which operation failed, what it was - trying to do, what prevented it, and the consequence. Include relevant tool names, - paths, arguments, configuration values, or limits. `outcome="failed"` alone is - not an explanation. -- Preserve the original error and its cause. Distinguish observed facts from - inference; if the cause is unknown, say so. Do not replace specific errors with - generic messages such as "operation failed". -- Use stable event names and consistent fields. Include correlation IDs to connect - the report to surrounding events; IDs supplement an explanation, not replace it. -- Choose severity for the event's meaning. Routine lifecycle events belong at - DEBUG or INFO. Warnings must identify a condition or intervention worth - investigating—for example, a user refusing a model-requested tool action. - Explain its consequence, such as blocking the entire tool batch. -- Log the cause where it is known and the resulting outcome where it is committed. - Make their relationship explicit. Do not force readers to infer the cause from - an unrelated operation's generic failure. -- Test diagnostic usefulness, not just event presence. Inspect persisted output: - a WARNING/ERROR-only scan must expose the important exception or intervention; - surrounding INFO/DEBUG records must explain the sequence. Assert meaningful - context and severity, including the absence of warnings during routine success. -- Redact API keys without discarding the evidence needed to diagnose the problem. - -The central rule is: **a reader should need surrounding logs to understand the sequence, -not to discover what the warning means.** - -## Runtime conventions - -- Keep decisions pure and synchronous. Return the complete next state and commands; - commit and freeze state before dispatching commands. -- Keep controllers, callbacks, registries, credentials, persistence handles, and other - mutable resources outside domain snapshots. -- The shared host owns operation resources, child routing, cancellation, completion - ports, and tool ports. Do not duplicate those responsibilities in agent or session - code. -- The session layer owns journal staging, persistence receipts, reconciliation, - settlement, restore, and durable branch publication. Never release dependent work - before its matching append receipt commits. -- Commit individual tool results before releasing them to the tool batch. Preserve the - existing identity and correlation checks for turns, children, batches, and calls. -- Treat an indeterminate append as unknown, not failed. Reconcile the stable append ID - and bytes through a load before retrying or stopping. -- Policies and provider profiles are versioned, pure data/code bindings. Policy patches - take effect at committed boundaries; they do not replace live registries or mutate an - operation already in flight. -- Provider profiles only encode and decode. Fetch work runs through the shared host - operation machinery, and credentials and origins remain environment bindings rather - than journal data. -- The nonjournaled agent runtime publicly accepts user and abort events. The session - environment also admits system, policy, fork, compact, and close events; child - outcomes stay private. -- Persistence and logging lifetimes belong to the environment or caller. Diagnostic - logs never certify a journal commit. -- Use discriminated unions, branded domain values, and Zod validation at trust - boundaries. Reuse existing asynchronous test helpers and injected ports. - -## Session fixtures - -Run the fixture inspector when changing session ordering, persistence, policy, or -recovery behavior: - -```sh -bun run packages/core-agent/session/fixture-runner.ts -bun run packages/core-agent/session/fixture-runner.ts --v2 -``` - -Approved fixture baselines are review artifacts. Use `--update` only when intentionally -accepting a behavior change, then review the generated diff. Do not casually replace -baselines or commit ignored inspection output (`.session-artifacts/`). - -## Style and generated files - -Biome is the formatter (`bun run format`) and the linter; import order is not managed. Use two -spaces, double quotes, semicolons, trailing commas, and a 100-column width. The journal fixture -baselines under `packages/core-agent/session/fixtures` are written by the fixture runner through -Prettier (`packages/core-agent/prettier.config.mjs`) and are never formatted by hand. - -Preserve strict TypeScript behavior, including `noUncheckedIndexedAccess` and -`verbatimModuleSyntax`. Avoid unrelated formatting changes. Do not edit generated -output in `dist/`, dependencies in `node_modules/`, or ignored session inspection -artifacts. - -- Separate top-level declarations with exactly one blank line. This includes - functions, classes, types, interfaces, and module-level variables. -- Separate function declarations—including nested functions and functions assigned - to variables—from adjacent statements or declarations with one blank line. - No blank line is needed at the beginning or end of a scope. -- Check this spacing during diff review; formatter output does not waive the rule. diff --git a/docs/agent/session-model.md b/docs/agent/session-model.md deleted file mode 100644 index ae6d4ede..00000000 --- a/docs/agent/session-model.md +++ /dev/null @@ -1,187 +0,0 @@ -# Session model: facts, projections, effects and decisions - -This is the reference for how a session records what happened and how every view of it is produced. -When code or another document disagrees with this page, the code is wrong and the page wins. The -"Code today" notes list the known divergences; the correction work removes them. - -## Four concerns - -| Concern | What it is | Where it lives | Example | -| -------------- | ------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| **Fact** | Something that happened in the conversation | The session journal, written once | the user's text and attachments, the model's output, a tool's result | -| **Projection** | A view computed from facts | Nowhere; it is recomputed when needed | the next LLM prompt, the VS Code transcript, `/export` | -| **Effect** | An interaction with the outside world | The effect stream: logs, traces, subscribers | an HTTP request and response, token usage, raw tool output | -| **Decision** | A choice made at a named decision point | Pluggable policy code; its outcome is a fact when it changes the conversation | permission, provider selection, a budget veto | - -The journal is the input to every projection. It never stores projections. It holds facts and, -separately typed, the effects the environment chooses to record. Decisions read facts, -configuration and data gathered from effects, and they never re-derive a stored value in order to -refuse a write. - -## Facts: the session journal - -A fact is recorded once, in the order it happened, and never rewritten. Content is recorded as typed -parts: text, thinking, tool calls, and blob references for anything binary. - -| Fact | Records | -| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Session created | origin (root, fork of a parent, compaction of a parent), inherited or seeded context, starting configuration | -| Configuration applied | the settings in force from the next turn (provider, model, thinking, output limit, streaming, permissions, tool scope, tool-failure handling, projection) | -| User input | text and blob references | -| Model output | typed parts (text, thinking with any provider signature, tool calls, handoff), and which provider and model produced it | -| Tool result | the call it answers, and succeeded (typed parts), failed (failure), cancelled, refused by permission, or running in the background (placeholder) | -| Permission decision | what the user decided for each call | -| Decision outcome | a decision point's outcome that changed the conversation, for example a vetoed step with its reason | -| Turn ended | the outcome: completed, aborted, exhausted or failed, with the failure | -| Interruption and recovery | a turn closed on reload after process exit | -| Standing instructions | a new list of session instructions | -| Registry adopted | the live agents and tools a reopened session continues with | - -These are **not** facts: - -- the prompt sent for a step (a projection; its HTTP body is an effect); -- a copy of a turn's messages (derived from the facts above); -- values derived from other facts (a tool batch's collected results, whether a step needs permission, - the step allowance); -- token usage, cache reads and writes, cost, stop details, request IDs, timings (effects). - -Provider continuation data (for example a thinking signature or an encrypted reasoning item) is part -of the model output fact, because the next prompt to that provider needs it. - -### Recorded effects - -There is one journal, and each record is typed as a fact or an effect. The conversation fold reads -facts only. Effect records are checked for integrity and nothing else; they never gate load or new -work. The environment chooses which effects to record. The default records, for each step, what the -provider reports: token usage including cache reads and writes, the stop reason, the provider request -ID, and cost only when the provider reports it (the runtime never estimates it). Request and -response bodies are side-car logging: an optional event subscriber writes them to a separate, dense -log file for debugging. They are not a core concern, and the journal does not reference them. - -Any view of recorded effects is a projection, and what to show the user is a UI decision. For -example, usage after a restore is shown from the recorded usage effects. A decision can read them -too: a drop in cache reads to zero after a long pause shows that the provider's prompt cache has -expired, which is the point where a compact-and-fork policy can discard stale context without losing -a warm cache. - -### Blobs - -Binary content is stored once, addressed by its SHA-256, and referenced from facts by a structured -blob reference (hash, media type, size, optional name). Its URI form is `blob://.`. The -harness resolves a blob URI against whatever store holds the bytes. A model that reads a `blob://` URI -with a tool receives the content. - -## Projections - -A projection is a pure function of facts and its own inputs. It is never journaled, never compared -with a stored copy, and can change between versions without affecting load. - -**The next LLM prompt** is `project(facts, target, projection)`: - -1. `target` is the provider and model the step will call, with that model's capabilities. Each - capability is three-valued: supported, unsupported, or unknown. -2. The projection selects the history the policy asks for (full history, context only, or a handoff - packet computed from the handoff fact). -3. Each part is rendered for the target. A part the target supports is kept. A part the target does - not support, or might not support, becomes a pointer, for example - `[image/png, 68 KiB: blob://9f86d0….png]`. The model can read the pointer with a tool. -4. Provider continuation data is kept only for the provider that produced it. -5. The system prompt, standing instructions and the live tool list are added. -6. The provider adapter encodes the result. How a kept blob is sent (inline base64, a files endpoint, - a URL) is the adapter's choice. - -Other projections read the same facts: ACP `session/load` replay and live updates, the VS Code and -web transcripts, `/export` Markdown and JSONL, session titles, and `.session-artifacts/` dumps. They -share one message projection instead of each deriving conversation history separately. - -**Compaction** creates a new session whose seed is a lossy, possibly non-deterministic projection of -the parent (a summary, old images as pointers). The seed is a fact in the child and a projection of -the parent, which is why compaction creates a new session. - -## Effects - -Events are effects. Every event the runtime emits describes something it did to the outside world, -and it is emitted at the point where that happens: the host's operation boundary, the provider -transport around fetch, the tool runner, the permission and client ports, blob I/O and journal -appends. The emitter is supplied by the environment through session bindings. Pure decisions and the -journal fold never emit events; the fold also runs on load, where nothing happens. Committing a fact -emits a "fact committed" event after the append, which is how live views learn about it; the fact -itself is in the journal, not in the event stream. - -Effect events carry correlation IDs (session, turn, step, operation, tool call, HTTP request, append). -Typical events: the rendered request and its digest, the provider response or stream, token usage and -cost, tool start and end with a reference to raw output, permission requests, client file and -terminal calls. - -Subscribers are cross-cutting and optional: diagnostic logging, provider request/response capture, -usage and cost accounting, auditing, and hooks. Adding a subscriber changes no core code. - -## Decisions - -A decision point is a named seam where a policy chooses what happens next. Core ships default -policies; an environment can replace or add them without changing core. A policy may read facts, -configuration, and aggregates built from effects (for example usage totals kept by an accounting -subscriber). - -| Decision point | Default | Example replacement | -| --------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------- | -| input during a turn | barge-in while waiting for the model; otherwise queue | deliver as an interjection at the next step boundary | -| before a step is dispatched | proceed with the configured target | veto when a provider budget is exhausted; choose another provider from a list | -| tool permission | ask the user, or allow | allow by rule | -| tool failure | return the error to the model and continue | fail the turn | -| unknown capability | render a pointer | send and let the provider's error surface | - -A decision that changes the conversation is recorded as a fact: a vetoed step, a refused permission, -and the provider and model that produced an output. A decision that only chooses how to render or -encode something is not recorded. - -Usage and cost are effects. They become input to a decision only through a policy that reads them, -for example "use no more than 80% of my Anthropic subscription", followed by a provider selector -that picks the next provider to try. Core never refuses work on its own account because of an -effect. - -## Configuration - -Changing configuration is two transitions: - -1. **Select.** The user's choice is committed to the session's configuration store immediately, at - any time, including while a turn is running. -2. **Apply.** At the next boundary between turns, the runtime compares the selection with the - configuration in force and, when they differ, records a "configuration applied" fact. The next - turn runs with it; a running turn keeps the settings it started with. - -Views show both: the selected configuration and the configuration the transcript ran under. - -## Load - -Loading is a fold over the journal, checked for integrity only: record format, revision and batch -continuity, identities, the creation record first, and correlation (every result names a call, turn -or input that exists). It does not re-run commit-time rules or recompute projections. Reopening a -session never requires today's models, tools or settings to match those of the past. - -## Invariants that stay - -- A tool result answers exactly one call of the step that proposed it; a call is answered once. -- The next step waits for its inputs: every call of the previous step has a result or a placeholder. -- A fact is committed before any work that depends on it is released. -- Credentials never enter facts, projections or effect events; effect subscribers redact. - -## Code today - -The remaining divergences: - -- Token usage (with its HTTP provenance) is the only recorded effect: core stages it as an `effect` - record in the step's append whenever the completion reports it; the environment cannot yet choose - which effects to record, and cost and stop details are not recorded. -- Media capability has no source for `unknown`: `mediaCapability()` marks every media kind a bound - profile declares as supported and every other kind as unsupported, because the committed model - catalog carries no per-model modality data. The projection already renders `unknown` as a pointer, - and there is no replaceable policy for it. -- There is no decision point before a step is dispatched (no budget veto or provider selector). -- The core defaults differ from the table above: the default policy pack rejects input while tools - run (`reject-during-tools`) instead of queueing it, and fails the turn on a tool failure - (`fail-turn`); `queued@1` and `tolerant@1` select the other behaviours. -- Journal appends, loads and session lifecycle (`session/runtime/`, `session-operation.ts`) still - log with direct `diagnostic()` calls instead of emitting typed effect events through the bound - emitter, and there is no "fact committed" event: live views observe session snapshots. The ACP - client ports (files, terminal, elicitation, MCP) also log directly. diff --git a/docs/agent/session-persistence.md b/docs/agent/session-persistence.md deleted file mode 100644 index 3b43e071..00000000 --- a/docs/agent/session-persistence.md +++ /dev/null @@ -1,43 +0,0 @@ -# Journal and blob persistence - -The journal records execution decisions; blobs retain content too large or unsuitable for those -records. Keep both for a usable restored session. A valid journal can restore without reading blobs, -but the next request may fail if its attachment bytes were deleted. - -`SessionPersistence` has independent journal (`load`, `append`) and object-store (`putBlob`, -`getBlob`) operations. Every operation takes an AbortSignal. Journal receipts and stable append-ID -reconciliation retain their existing semantics. Blob operations do not advance journal revisions. - -Blobs are session-scoped and immutable. `BlobId` is the lowercase hex SHA-256 of raw bytes. -`putBlob` accepts a Uint8Array and `{ media, name? }`, rejects more than 8 MiB before writing, -and copies caller bytes before returning control. Identical bytes return the same ID and stored -metadata on retry. The first optional name is retained; attempting to change the media type for -already-stored bytes is rejected. Zero-byte blobs are valid. Metadata is immutable and get returns -an independent byte buffer. A cancelled call rejects; an absent get returns `{ kind: "not_found" }`. - -Supported ref media types are text/plain, text/markdown, image/png, image/jpeg and application/pdf. -The ref schema admitting a media type does not imply a profile can encode it. The shipped Anthropic @2–@4 profiles declare PDF document support. Each step, after projecting its prompt and before its HTTP request, verifies stored size, -media and hash against the ref for each blob the projected prompt still sends; a part the target -cannot, or might not, read is already a pointer and is not loaded. Ref names are display labels, not paths. - -The environment puts bytes before admitting a user ref. The current journal format stores refs and content parts, -never the raw bytes. Loading decodes refs as part of each record and does not read the object store. -Idle restoration therefore succeeds without blob access; missing bytes fail the next step that sends them, before its HTTP request. - -Large continuation payloads are serialized as UTF-8 JSON blobs before model_settled is journaled. -Their envelopes carry `payloadBlob` instead of `payload`, using the same format. The inline cap stays at -65,536 JSON characters and the blob cap at 8 MiB raw. A step attaches these refs to its request -without reading them first; missing/corrupt payload bytes fail the step before HTTP. Restore -and replay never resolve them. A failed append may leave an unreferenced payload blob. - -Branch publication copies only blobs cited by inherited history/context and retained continuation -owners, using get/put through the -same port. Compaction copies the replacement-context subset. Copies finish before child creation -is published; no parent objects are removed. Content-addressing makes copying retryable, but there -is no atomic transaction spanning child blob writes and journal creation, nor garbage collection. - -`createMemoryBacking` shares journal streams and session blob maps across independent memory -adapter instances. It has process-local lifetime only and provides no crash durability. The reusable -`testing/persistence-contract.ts` suite checks hashing, scope, retries, absent IDs, cancellation, -size limits and alias safety in addition to the existing append/load contract. Durable adapters -must add storage-specific process-crash tests and preserve both methods' lifetime guarantees. diff --git a/docs/agent/session-runtime.md b/docs/agent/session-runtime.md deleted file mode 100644 index 4e40890d..00000000 --- a/docs/agent/session-runtime.md +++ /dev/null @@ -1,289 +0,0 @@ -# Where a session's guarantees come from - -A session joins two systems that cannot share a transaction: its journal and the outside world. -The journal can prove that a tool was authorized and that a result was saved. It cannot prove that -an interrupted external write did not happen. The design therefore commits before releasing work -and stops interrupted work on restore instead of replaying it. - -These diagrams show the boundaries an integrator must preserve. They omit mailbox plumbing; -[core actor diagrams](agent-flow-diagrams.md) cover replacement and cancellation inside a turn. - -## Keep execution, decisions, and storage separate - -```mermaid -flowchart LR - caller[Application] - session[Session: admission and receipt gates] - decisions[Pure decisions: next state and commands] - store[(Caller-owned persistence)] - host[Shared host: operation lifetime] - tools[Tool bindings] - transport[Provider transport: HTTP and capture] - profile[Pure profile: encode and decode] - external[External services] - caller -->|input or configuration| session - session <--> decisions - session <-->|append and load| store - session -->|commands after matching receipt| host - host --> tools - host --> transport - transport <-->|data conversion| profile - tools <--> external - transport <-->|one HTTP attempt| external - host -->|correlated outcomes| session - session -->|admission and settlement| caller -``` - -Profiles cannot fetch, and decisions cannot perform I/O. This separation lets replay reconstruct -state without touching an external service. Bindings and credentials remain environment resources; -they must be supplied again on restore. - -## Admission and settlement answer different questions - -```mermaid -sequenceDiagram - participant A as Application - participant S as Session - participant P as Persistence - participant H as Host - A->>S: input(text) - S->>P: append accepted input - P-->>S: matching committed receipt - S-->>A: accepted - S->>H: start the step (project prompt, call model) - Note over S,H: The prompt is projected inside the step and never journaled.
Each step output commits under its own receipt gate before tools or the next step start. - H-->>S: outcome that ends the turn - S->>P: append outcome and terminal record - P-->>S: matching committed receipt - S-->>A: settled with the turn record the fold derived -``` - -The two arrows following a receipt express eligibility, not a promise about callback scheduling. -Await admission to know input was saved; await settlement to learn how it ended. Neither a resolved -promise nor a display notification implies a successful answer. Inspect the returned discriminants. - -## Permission must precede the whole batch - -```mermaid -flowchart TD - model[Validated model tool calls] --> intent[Commit model outcome] - intent --> mode{Permission policy?} - mode -->|off| run[Start tool batch] - mode -->|ask| ask[Ask once for each call in order] - ask --> answer{Decision} - answer -->|each call allowed or refused| approval[Commit permission outcome] - approval -->|refused calls get a permission-refused result| run - answer -->|cancelled| abort[Commit aborted turn] - answer -->|invalid response or port failure| fail[Commit failed turn with cause] -``` - -No tool runs while the batch is waiting. Approval of call A does not allow A to start while call B -is still undecided. A notification showing a pending tool is display evidence only. Permission -waiting has no core timeout; the caller may cancel it. - -## Save each tool result before continuing - -```mermaid -sequenceDiagram - participant T as Tool - participant H as Host - participant S as Session - participant P as Persistence - participant B as Batch - T-->>H: validated result or failure - H-->>S: correlated tool outcome - Note over H,S: A terminal display update can already be visible. - S->>P: append individual tool outcome - P-->>S: matching committed receipt - S->>H: releaseTool(outcome) - H->>B: accept result - B-->>S: batch outcome, through host, when settled - S->>P: append batch outcome - P-->>S: matching committed receipt - S->>H: next commands, if the turn continues -``` - -Saving only the final batch would lose completed siblings if another tool hangs or the process -stops. A durable partial result remains available for recovery even when the batch never completes. -The batch may settle early on failure/cancellation; it does not always wait for every tool to succeed. - -## A lost receipt is uncertainty, not failure - -```mermaid -flowchart TD - append[Append attempt] --> result{Result} - result -->|matching receipt| release[Promote durable state and release work] - result -->|conflict or rejection| stop[Stop session] - result -->|indeterminate| load[Consistent load and integrity check] - load --> found{Identical append at stream tip?} - found -->|yes| release - found -->|no| absent{Absent at expected revision and retry unused?} - absent -->|yes| retry[Retry same append ID and bytes once] - absent -->|no| stop - retry -->|matching receipt| release - retry -->|other outcome| stop - load -->|load or integrity failure| stop -``` - -Only the storage append is retried. No completion or tool is repeated. The persistence adapter must -ensure a request cannot commit later after a consistent load reports it absent. Otherwise the -runtime cannot safely decide whether dependent work may start. Close or AbortSignal cancellation -alone is also not a storage result; restore after close to discover a dispatched append's outcome. - -## Input waiting and configuration are different cases - -A storage append can temporarily queue submissions in memory; that queue is lost on process exit. -A `queue-user` policy instead saves user input for a later turn. Do not label both as “accepted”: -only the receipt establishes durable admission. - -System instruction changes return `busy` while a turn or durable queued input is outstanding, -including staged active work. This check happens before ordinary transient queueing. Wait for the -existing work to drain, commit the change, then submit the next input. - -Configuration changes are never `busy`. Selecting stores the new settings in the persistence -port's configuration store at once; the session actor keeps the pending selection in its -non-durable state and, at the next boundary between turns, stages it as a "configuration applied" -`policy` record before dequeuing queued input or admitting new input. A turn already running keeps -the settings it started with. A selection equal to the policy in force records nothing. Restore -applies a selection stored before the process stopped, after recovery and registry adoption. -Configuration changes do not change the journal format. - -```mermaid -sequenceDiagram - participant C as Caller - participant S as Session - participant K as Configuration store - participant J as Journal - C->>S: updatePolicy(patch) during a turn - S->>K: putConfig(selection) - S-->>C: selected - Note over S: running turn keeps its settings - S->>J: terminal (turn ends) - S->>J: policy (configuration applied) - S->>J: dequeued or next user input -``` - -## Restore inspects history and closes interrupted work - -```mermaid -flowchart TD - load[Load journal and check integrity] --> valid{Intact?} - valid -->|no| reject[Reject restore naming the violated rule and record] - valid -->|yes| work{Unfinished work?} - work -->|none| registry - work -->|active turn or saved queued inputs| stage[Stage recovery batch] - stage --> active{Active turn?} - active -->|yes| terminal[Include interrupted terminal failure and committed partial results] - active -->|no| queued[Include cancellation of any saved queued inputs] - terminal --> queued - queued --> append[Commit one atomic recovery batch] - append -->|receipt or reconciled commit| registry{Live registry differs?} - append -->|cannot establish commit| reject - registry -->|no| ready - registry -->|yes| pending[Return runtime with pending registry adoption] -``` - -An idle session with only queued inputs gets cancellations, not an invented failed turn. None of -these paths invokes completion, tool, or permission bindings. Blob bytes are read only when needed -for later work, so restoration is not a test of attachment availability. Repeating an uncertain -action requires a new explicit invocation after restoration. - -## Restore adopts the live registry and bindings - -Stored history is a record of what happened, not a constraint on what may happen next. Loading checks -journal integrity only: record decoding, batch continuity, the revision sequence, append and entry -IDs, session identity, the creation record first, and each turn's terminal record in the same batch -as the record that ended the turn. A record must also name a turn, operation, tool batch, call or -queued input that exists in the folded state, because the fold cannot apply it otherwise. Load never -re-runs commit-time rules (configuration validation, permissions, admission) and never asks whether -a provider, model, profile setting or permission port named in a record is bound today. No prompt -is stored, so none is compared. A configuration-applied record's policy, and a terminal record's -agent and outcome, are facts and are taken as written; an effect record loads whatever state the -turn is in. New work is still staged under every commit-time rule, and new configuration selections -are validated against live bindings before they are admitted. - -If the live agents or tool schemas differ from the journal's registry, or the current policy does -not validate against the live bindings, restore still succeeds and reports -`registry: { kind: "pending_adoption", differences }`; `session.policy` is the policy the next turn -will use. Tool differences name the schema delta, e.g. `changed tools.read_file.parameters: added -properties line, limit`. Opening writes nothing beyond a recovery batch, unless a configuration -selection stored before the process stopped differs from the journaled policy: restore then applies -it, which is new work. The first input, system or configuration change, fork or compaction (or that -restored selection) first appends a `configuration` record with stable ID -`configuration//`, then the work queues behind it: - -```mermaid -sequenceDiagram - participant A as Application - participant S as Session - participant J as Journal - A->>S: input - S->>J: configuration (live registry, reconciled policy/agent) - J-->>S: committed - S->>J: user input - J-->>S: committed - S-->>A: turn runs with live tools; registry is current -``` - -Steps staged after that record are admitted against the new registry (a proposed tool call must be -one the active agent may use). The record replaces the registry and, only when needed, reconciles: - -- `policy`: a new policy version. Per-agent tool permissions drop unregistered tools and removed - agents; new agents are derived as creation does. A policy pack `id`, projection (`project`) or - `handoff` the live resolvers do not supply takes the value a new session would get. Provider, - model, thinking, thinking budget, stream, output limit and permission mode that the live bindings - reject are replaced by the policy a new session would get, changing the fewest fields (the saved - provider/model is kept when some combination allows it). No other policy field may change; the - result is validated. -- `agent`: the live default agent, only when the idle conversation's agent is no longer registered. - -Each change is logged as `session.registry.reconciled` with the previous and next values and the -consequence (for example `next turn uses anthropic/claude-opus-4-5` or `next turn projects history -with history@1`). A changed model, agent, projection or handoff is a warning, because it changes -what the model sees; a changed pack ID alone is info. - -History is never filtered or rewritten. The next prompt projects every earlier message, including -calls to and results from tools that are no longer registered, and advertises only live tools. If -the adoption append fails, the session fails and queued work settles with that storage cause. - -## Branch publication has two persistence boundaries - -```mermaid -sequenceDiagram - participant A as Application - participant S as Parent session - participant P as Parent journal - participant C as Child storage - A->>S: fork or compact - S->>P: save branch request with child ID - P-->>S: committed - Note over S,P: If a turn is active, wait for its committed terminal boundary.
Capture excludes queued successor inputs. - S->>C: copy referenced blobs and commit child seed - C-->>S: child creation committed - S-->>A: publish independent child runtime -``` - -A fork retains history; compaction uses your replacement context. Neither copies live operations or -mutates the parent. There is no transaction across parent and child streams. If publication is -interrupted, inspect the saved child ID and restore it rather than issuing another fork blindly. - -## Model selection and observable traffic - -```mermaid -flowchart LR - selection[Captured provider, model, settings] --> binding[Environment resolves wire ID and profile] - binding --> validation[Validate declared capabilities] - validation --> encode[Pure encoder] - encode --> request[Retain actual request] - request --> http[HTTP attempt with operation signal] - http --> response[Retain response or partial stream] - response --> decode[Assemble, decode, and validate] - decode --> outcome[Typed completion outcome] - outcome --> journal[Session receipt gate] - response -->|invalid or incomplete| failure[Failure with operation and cause] -``` - -The capture sink is environment-owned and opt-in. A successful response is not required to retain -the offending body: JSON parse failures and stream EOF leave evidence. Capture is separate from -normal diagnostic logs and from the journal; each answers a different question. See -[logging](../../packages/core-agent/logging/README.md) and [capture setup](../../packages/core-agent/environment/README.md). diff --git a/package.json b/package.json index dce73abf..0c043cf4 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,6 @@ "probe:dogfood": "zsh scripts/db/probe-dogfood.sh", "record:sync": "bun scripts/db/sync-record.ts", "snapshot:record": "bun scripts/db/snapshot-record.ts", - "test:agent:logs": "LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun run test:agent", - "test:agent": "bun test --timeout 20000 packages/core-agent", "test:migration-safety": "bun scripts/db/test-migration-safety.ts", "test:pg": "bash scripts/checks/test-postgres.sh", "test": "bun test --timeout 20000", diff --git a/packages/app-vscode/package.json b/packages/app-vscode/package.json index d2eb6cd0..bf50b0a4 100644 --- a/packages/app-vscode/package.json +++ b/packages/app-vscode/package.json @@ -316,7 +316,6 @@ }, "dependencies": { "@agentclientprotocol/sdk": "catalog:", - "@labkit/core-agent": "workspace:*", "@labkit/ui": "workspace:*", "@labkit/view-model": "workspace:*", "react": "catalog:", diff --git a/packages/app-vscode/src/handlers/FileSystemHandler.ts b/packages/app-vscode/src/handlers/FileSystemHandler.ts index 83bfb23c..d20a69d5 100644 --- a/packages/app-vscode/src/handlers/FileSystemHandler.ts +++ b/packages/app-vscode/src/handlers/FileSystemHandler.ts @@ -9,7 +9,7 @@ import type { } from "@agentclientprotocol/sdk"; import * as vscode from "vscode"; -import { diagnosticError } from "@labkit/core-agent/logging"; +import { diagnosticError } from "../utils/redact"; import { logDiagnostic } from "../utils/Logger"; /** The editor owns text and saving. Direct disk writes would bypass dirty buffers. */ diff --git a/packages/app-vscode/src/handlers/PermissionHandler.ts b/packages/app-vscode/src/handlers/PermissionHandler.ts index 570a43cf..9acd453f 100644 --- a/packages/app-vscode/src/handlers/PermissionHandler.ts +++ b/packages/app-vscode/src/handlers/PermissionHandler.ts @@ -6,7 +6,7 @@ import type { } from "@agentclientprotocol/sdk"; import * as vscode from "vscode"; -import { diagnosticError } from "@labkit/core-agent/logging"; +import { diagnosticError } from "../utils/redact"; import { logDiagnostic } from "../utils/Logger"; type Option = RequestPermissionRequest["options"][number]; diff --git a/packages/app-vscode/src/handlers/TerminalHandler.ts b/packages/app-vscode/src/handlers/TerminalHandler.ts index bb8a8af1..46d4974f 100644 --- a/packages/app-vscode/src/handlers/TerminalHandler.ts +++ b/packages/app-vscode/src/handlers/TerminalHandler.ts @@ -17,7 +17,7 @@ import type { } from "@agentclientprotocol/sdk"; import * as vscode from "vscode"; -import { diagnosticError } from "@labkit/core-agent/logging"; +import { diagnosticError } from "../utils/redact"; import { logDiagnostic, registerEnvironmentSecrets } from "../utils/Logger"; export type TerminalDisplay = TerminalOutputResponse & { diff --git a/packages/app-vscode/src/utils/Logger.ts b/packages/app-vscode/src/utils/Logger.ts index d196f28b..adff2bfb 100644 --- a/packages/app-vscode/src/utils/Logger.ts +++ b/packages/app-vscode/src/utils/Logger.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { diagnosticError, redactDiagnostics } from "@labkit/core-agent/logging"; +import { diagnosticError, redactDiagnostics } from "./redact"; import { fileDiagnostics } from "./file-diagnostics"; let output: vscode.OutputChannel | undefined; diff --git a/packages/app-vscode/src/utils/file-diagnostics.ts b/packages/app-vscode/src/utils/file-diagnostics.ts index 08f54fe1..ba43aba5 100644 --- a/packages/app-vscode/src/utils/file-diagnostics.ts +++ b/packages/app-vscode/src/utils/file-diagnostics.ts @@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { rotatingFileSink } from "./rotating-file-sink.ts"; -import { redactDiagnostics } from "@labkit/core-agent/logging"; +import { redactDiagnostics } from "./redact"; /** One bounded log set per extension storage directory, across restarts. */ export function fileDiagnostics( diff --git a/packages/app-vscode/src/utils/redact.test.ts b/packages/app-vscode/src/utils/redact.test.ts new file mode 100644 index 00000000..a67ec98e --- /dev/null +++ b/packages/app-vscode/src/utils/redact.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { redactDiagnostics } from "./redact"; + +test("redaction targets credentials and preserves diagnostic details including exact-key echoes", () => { + const output = redactDiagnostics( + { + path: "/workspace/auth/token.ts", + maxOutputTokens: 4096, + tokenUsage: { output: 3000 }, + url: "https://example.invalid/messages?api_key=secret-value&model=sonnet", + body: "Provider rejected key arbitrary-provider-credential: invalid account scope", + headers: new Headers({ "x-api-key": "secret-value", "request-id": "req-5" }), + cause: new Error("failure details"), + }, + ["arbitrary-provider-credential"], + ); + const serialized = JSON.stringify(output); + for (const detail of [ + "/workspace/auth/token.ts", + "4096", + "3000", + "model=sonnet", + "invalid account scope", + "req-5", + "failure details", + ]) { + expect(serialized).toContain(detail); + } + expect(serialized).not.toContain("secret-value"); + expect(serialized).not.toContain("arbitrary-provider-credential"); + const cyclic: Record = {}; + cyclic.cause = cyclic; + expect(redactDiagnostics(cyclic)).toEqual({ cause: "[Circular]" }); +}); diff --git a/packages/core-agent/logging/index.ts b/packages/app-vscode/src/utils/redact.ts similarity index 59% rename from packages/core-agent/logging/index.ts rename to packages/app-vscode/src/utils/redact.ts index bef07613..343b8701 100644 --- a/packages/core-agent/logging/index.ts +++ b/packages/app-vscode/src/utils/redact.ts @@ -1,22 +1,7 @@ -import { getLogger, type LogLevel } from "@logtape/logtape"; - -// Configuration and sink lifetime belong to the environment, never to a session. -export { - configure, - reset, - getConsoleSink, - getStreamSink, - getJsonLinesFormatter, - fromAsyncSink, - type LogLevel, - type LogRecord, - type Sink, -} from "@logtape/logtape"; - -export type DiagnosticFields = Readonly>; - -/** A caught error, redacted and flattened by {@link diagnosticError}: identity, stack, cause, code. */ -export type DiagnosticError = Readonly>; +/** + * Redaction for the extension's diagnostics: credential fields and recognizable credential text are + * replaced, and every other detail is kept, so a log line still says what failed. + */ const secretField = /^(?:api[-_]?key|x[-_]api[-_]key|authorization|proxy[-_]authorization|password|client[-_]?secret|access[-_]?token|refresh[-_]?token|id[-_]?token|cookie|set-cookie)$/i; @@ -79,36 +64,3 @@ export function diagnosticError( ? (detail as Record) : { message: detail }; } - -/** Best-effort diagnostics cannot change actor execution or durable decisions. */ -export function diagnostic( - category: string, - level: LogLevel, - event: string, - fields: DiagnosticFields = {}, -): void { - try { - const properties = redactDiagnostics({ ...fields, event }) as Record; - const detail = Object.entries(properties) - .filter(([key, value]) => key !== "event" && value !== undefined) - .map(([key, value]) => `${key}=${JSON.stringify(value)}`) - .join(" "); - getLogger(["labkit", category]).emit({ - level, - timestamp: Date.now(), - rawMessage: event, - message: [detail ? `${event} ${detail}` : event], - properties, - }); - } catch { - // A failing synchronous sink must not become an operation failure. - // Async sinks must use LogTape's fromAsyncSink (which handles rejections). - } -} - -/** Immutable explicit context; safe for concurrent sessions and browser runtimes. */ -export function diagnosticContext(category: string, context: DiagnosticFields) { - const bound = { ...context }; - return (level: LogLevel, event: string, fields: DiagnosticFields = {}): void => - diagnostic(category, level, event, { ...bound, ...fields }); -} diff --git a/packages/core-agent/README.md b/packages/core-agent/README.md deleted file mode 100644 index e78f8a0f..00000000 --- a/packages/core-agent/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# @labkit/core-agent - -Internal, Bun-first package for durable agent sessions. The package exports TypeScript source; -Bun runs it directly and TypeScript consumes its types without a build step. It depends only on -Zod and LogTape. Attachment hashing uses Bun APIs, so Node/browser execution is not supported by -this package contract. `private: true` prevents accidental registry publication; local links and -packed tarballs still work. - -## Choose your integration boundary - -Start with the [session guide](session/README.md) if your application needs saved history and an -explanation after interruption. It distinguishes accepted input, displayed progress, and committed -outcomes; those are different promises. Use `/agent` only when process-local history is sufficient. - -Supply [tools/completions](host/README.md), [model bindings](providers/README.md), and persistence; -core runs the loop. The [environment guide](environment/README.md) explains input/render lifetimes -and request capture. [Policy](policy/README.md) controls admission and what context reaches a model. -[Logging](logging/README.md) explains where to find the cause when execution stops. - -## Use from another package - -Packages in this repository depend on it with `"@labkit/core-agent": "workspace:*"`. Source edits -are available immediately; restart the consuming process to load them. Only the process-local -memory persistence adapter is exported under `/testing`; it is not durable storage. - -## Usage - -```ts -import { createSession } from "@labkit/core-agent"; -import { createMemoryPersistence } from "@labkit/core-agent/testing"; - -const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { - agent: "assistant", - agents: new Map([["assistant", { model: "test-model" }]]), - steps: 4, - }, - bindings: { - complete: () => ({ kind: "answer", text: "Hello from core" }), - }, -}); -const result = await session.input("Hello").settled; -console.log(result); -await session.close(); -``` - -The root export is the session API. Explicit subpaths provide `/session`, `/agent` (nonjournaled -runtime), `/types`, `/content`, `/host`, `/providers`, `/policy`, `/environment`, `/logging`, `/fsm`, -and `/testing`. Internal deep imports are not exported. Consumers using TypeScript should use -`moduleResolution: "bundler"`, `noEmit: true`, and Bun types (`bun add --dev @types/bun`). -See each module's README for runtime, persistence, provider and permission contracts. diff --git a/packages/core-agent/agent/agent-conversation.test.ts b/packages/core-agent/agent/agent-conversation.test.ts deleted file mode 100644 index 008c0da6..00000000 --- a/packages/core-agent/agent/agent-conversation.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { Actor } from "../fsm/fsm.ts"; -import { - decideConversation, - initialConversation, - type ConversationCommand, - type ConversationEvent, - type ConversationState, -} from "./agent-conversation.ts"; -import { context } from "./test-support.ts"; - -function conversation() { - const { agent, steps } = context(); - return new Actor( - initialConversation(agent, steps), - decideConversation, - () => undefined, - (command) => ({ type: "dispatch_failed", command, error: { message: "failed" } }), - ); -} -test("recording and replacement commit before the next queued user event", async () => { - const parent = conversation(); - await parent.send({ type: "user", text: "first" }); - const first = parent.snapshot; - await Promise.all([ - parent.send({ type: "abort" }), - parent.send({ type: "user", text: "second" }), - ]); - expect(parent.snapshot.log).toMatchObject([ - { agent: "writer", outcome: { kind: "aborted" }, messages: [{ role: "user", text: "first" }] }, - ]); - expect(parent.snapshot.turnId).not.toBe(first.turnId); - const current = parent.snapshot; - await parent.send({ type: "child", turnId: first.turnId, event: { type: "abort" } }); - expect(parent.snapshot).toBe(current); - expect(first.log).toEqual([]); -}); -test("a failed step records an outcome and restores the turn allowance", async () => { - const parent = conversation(); - await parent.send({ type: "user", text: "first" }); - const state = parent.snapshot; - if (state.turn.status !== "awaiting_model") throw new Error(); - await parent.send({ - type: "child", - turnId: state.turnId, - event: { - type: "model_settled", - child: state.turn.child, - result: { kind: "failed", error: { message: "offline" } }, - }, - }); - expect(parent.snapshot.log[0]?.outcome).toEqual({ - kind: "failed", - error: { message: "offline" }, - }); - expect(parent.snapshot.turn).toMatchObject({ - status: "idle", - id: parent.snapshot.turnId, - agent: "writer", - steps: 6, - }); - expect(Object.isFrozen(parent.snapshot.log[0]?.messages)).toBe(true); -}); - -test("a rejected fork identity preserves the active state and cannot poison later outcomes", async () => { - const parent = conversation(); - await parent.send({ type: "user", text: "work" }); - const before = parent.snapshot; - await expect( - parent.send({ - type: "request", - request: { kind: "fork", id: before.turnId, sessionId: before.sessionId }, - }), - ).rejects.toThrow("new session identity"); - expect(parent.snapshot).toBe(before); - expect(parent.snapshot.pending).toHaveLength(0); - await parent.send({ type: "abort" }); - expect(parent.snapshot.log[0]?.outcome.kind).toBe("aborted"); -}); diff --git a/packages/core-agent/agent/agent-conversation.ts b/packages/core-agent/agent/agent-conversation.ts deleted file mode 100644 index be51b6b5..00000000 --- a/packages/core-agent/agent/agent-conversation.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { defineMachine, stay, type Decision } from "../fsm/fsm.ts"; -import { decideTurn, type TurnCommand, type TurnEvent, type TurnState } from "./agent-fsm.ts"; -import { parseSessionContext, type SessionContext } from "./prompt.ts"; -import { - ActorIdSchema, - SessionIdSchema, - type ActorId, - type AgentId, - type Failure, - type SessionId, - type Steps, - type TurnRecord, - type UserEvent, -} from "./types.ts"; - -/** - * A request to branch this conversation into a new session. It waits in `pending` until no turn is - * active, then is answered with a {@link SessionReply}. - * - `fork`: a child session that inherits the history and session context. - * - `compact`: a child session whose history is empty and whose session context is replaced by `context`. - */ -export type SessionRequest = - | Readonly<{ kind: "fork"; id: ActorId; sessionId: SessionId }> - | Readonly<{ kind: "compact"; id: ActorId; sessionId: SessionId; context: SessionContext }>; -/** - * State of one session's conversation: the settled turns so far and the current turn. - * `turn` is never `done`: a finished turn is appended to `log` and replaced by a fresh idle turn. - */ -export type ConversationState = Readonly<{ - status: "open"; - sessionId: SessionId; - /** Where the session came from; `sequence` is the parent's turn sequence at the branch point. */ - origin: - | Readonly<{ kind: "root" }> - | Readonly<{ kind: "fork" | "compaction"; parent: SessionId; sequence: number }>; - context: SessionContext; - /** Identity of the current turn, `/turn/`. */ - turnId: ActorId; - turn: Exclude; - /** Records of the turns that ended in this session, oldest first. */ - log: readonly TurnRecord[]; - /** Step allowance each new turn starts with. */ - allowance: Steps; - /** 1-based sequence number of the current turn. */ - sequence: number; - /** Fork and compaction requests waiting for the current turn to end (not queued user input). */ - pending: readonly SessionRequest[]; -}>; -/** Initial conversation state of a forked or compacted session: idle, with no pending requests. */ -export type ForkSnapshot = Omit & - Readonly<{ - turn: Extract; - pending: readonly []; - }>; -/** Answer to a {@link SessionRequest}, carrying the new session's initial state. */ -export type SessionReply = Readonly<{ kind: "forked"; state: ForkSnapshot }>; -/** Input to the conversation machine. */ -export type ConversationEvent = - /** User input or abort, forwarded to the current turn. */ - | UserEvent - /** Queue a fork or compaction request. */ - | { type: "request"; request: SessionRequest } - /** Settlement of a turn's child operation; ignored unless `turnId` is the current turn. */ - | { type: "child"; turnId: ActorId; event: TurnEvent } - /** Executing `command` threw; a turn command becomes a `failed` event for the current turn. */ - | { type: "dispatch_failed"; command: ConversationCommand; error: Failure }; -/** - * Effect the conversation asks its runtime to perform: dispatch a turn's command to the host, or - * deliver a reply to a waiting {@link SessionRequest}. - */ -export type ConversationCommand = - | Readonly<{ type: "turn"; turnId: ActorId; command: TurnCommand }> - | Readonly<{ type: "reply"; requestId: ActorId; result: SessionReply }>; -type D = Decision; -const turnIdentity = (session: SessionId, sequence: number) => - ActorIdSchema.parse(`${session}/turn/${sequence}`); - -/** - * Creates the state of a new root session with an idle first turn. - * @param allowance Steps each turn may use. - * @param sessionId Defaults to a random UUID. - */ -export function initialConversation( - agent: AgentId, - allowance: Steps, - sessionId: SessionId = SessionIdSchema.parse(crypto.randomUUID()), -): ConversationState { - const id = turnIdentity(sessionId, 1); - return { - status: "open", - sessionId, - origin: { kind: "root" }, - context: parseSessionContext([]), - turnId: id, - turn: { status: "idle", id, agent, steps: allowance }, - log: [], - allowance, - sequence: 1, - pending: [], - }; -} - -function advance(state: ConversationState, event: TurnEvent): D { - if ( - event.type === "user" && - ["awaiting_permission", "executing_tools", "cancelling_tools"].includes(state.turn.status) - ) { - throw new Error("Cannot accept user input while tools are active; abort the turn first"); - } - const decision = decideTurn(state.turn, event); - const commands: ConversationCommand[] = decision.commands.map((command) => ({ - type: "turn", - turnId: state.turnId, - command, - })); - if (decision.state.status !== "done") - return { state: { ...state, turn: decision.state }, commands }; - const record = decision.state.record; - const sequence = state.sequence + 1; - const id = turnIdentity(state.sessionId, sequence); - return { - state: { - ...state, - sequence, - turnId: id, - log: [...state.log, record], - turn: { status: "idle", id, agent: record.agent, steps: state.allowance }, - }, - commands, - }; -} - -/** Publish forks only between turns, without changing user input or abort semantics. */ -function drain(initial: D): D { - let state = initial.state; - const commands = [...initial.commands]; - while (state.turn.status === "idle" && state.pending.length) { - const idle = state.turn; - const [branch, ...pending] = state.pending; - if (!branch) break; - state = { ...state, pending }; - const sequence = branch.kind === "compact" ? 1 : state.sequence; - const id = turnIdentity(branch.sessionId, sequence); - const fork: ForkSnapshot = { - ...state, - sessionId: branch.sessionId, - sequence, - turnId: id, - pending: [], - origin: { - kind: branch.kind === "compact" ? "compaction" : "fork", - parent: state.sessionId, - sequence: state.sequence, - }, - context: branch.kind === "compact" ? branch.context : state.context, - log: branch.kind === "compact" ? [] : state.log, - turn: { ...idle, id }, - }; - commands.push({ type: "reply", requestId: branch.id, result: { kind: "forked", state: fork } }); - } - return { state, commands }; -} - -/** - * The conversation machine: forwards user input, abort and child settlements to the current turn, - * starts a new idle turn when one ends, and answers pending fork and compaction requests once no turn - * is active. Turn events are unaffected by pending requests. - * @throws When user input arrives while permission or tools are pending ("abort the turn first"), - * and when a request reuses this session's id. The throw rejects the `send` that delivered the event. - */ -export const decideConversation = defineMachine< - ConversationState, - ConversationEvent, - ConversationCommand ->({ - open: { - user: (state, event) => drain(advance(state, event)), - abort: (state, event) => drain(advance(state, event)), - request: (state, event) => { - if (event.request.sessionId === state.sessionId) - throw new Error("A fork requires a new session identity"); - return drain({ - state: { ...state, pending: [...state.pending, event.request] }, - commands: [], - }); - }, - child: (state, event) => - event.turnId === state.turnId ? drain(advance(state, event.event)) : stay(state), - dispatch_failed: (state, event) => - event.command.type === "turn" && event.command.turnId === state.turnId - ? drain( - advance(state, { - type: "failed", - child: event.command.command.child, - error: event.error, - }), - ) - : stay(state), - }, -}); diff --git a/packages/core-agent/agent/agent-fsm.test.ts b/packages/core-agent/agent/agent-fsm.test.ts deleted file mode 100644 index 094afaf8..00000000 --- a/packages/core-agent/agent/agent-fsm.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { admittedCompletionSchema, decideTurn, type TurnState } from "./agent-fsm.ts"; -import { context } from "./test-support.ts"; -import { ref } from "./types.ts"; - -const initial = (): TurnState => { - const { id, agent, steps } = context(); - return { status: "idle", id, agent, steps }; -}; -const admitted = admittedCompletionSchema(new Set(["writer", "reviewer"]), new Set(["search"])); -function awaiting() { - const next = decideTurn(initial(), { type: "user", text: "hello" }).state; - if (next.status !== "awaiting_model") throw new Error("Expected completion"); - return next; -} -test("barge-in replaces the reference and emits cancellation in one pure decision", () => { - const state = awaiting(); - const next = decideTurn(state, { type: "user", text: "actually" }); - expect(next.commands.map((command) => command.type)).toEqual(["cancel", "complete"]); - expect(next.state.status).toBe("awaiting_model"); - if (next.state.status !== "awaiting_model") throw new Error(); - expect(next.state.child.id).not.toBe(state.child.id); - expect(next.state.turn.messages.map((message) => message.text)).toEqual(["hello", "actually"]); - expect(state.turn.messages).toHaveLength(1); - expect( - decideTurn(next.state, { - type: "model_settled", - child: state.child, - result: { kind: "succeeded", value: admitted.parse({ kind: "answer", text: "late" }) }, - }).state, - ).toBe(next.state); -}); -test("handoff changes agent and child together; stale same-kind requests cannot satisfy it", () => { - const state = awaiting(); - expect( - decideTurn(state, { - type: "model_settled", - child: ref("completion", "old"), - result: { kind: "succeeded", value: admitted.parse({ kind: "answer", text: "late" }) }, - }).state, - ).toBe(state); - const next = decideTurn(state, { - type: "model_settled", - child: state.child, - result: { - kind: "succeeded", - value: admitted.parse({ kind: "handoff", text: "review", agent: "reviewer" }), - }, - }); - expect(next.state).toMatchObject({ - status: "awaiting_model", - turn: { agent: "reviewer", view: { kind: "handoff", from: "writer", at: 2 } }, - child: { kind: "completion" }, - }); - if (next.state.status !== "awaiting_model") throw new Error(); - expect(next.state.child.id).not.toBe(state.child.id); - expect(next.commands).toEqual([ - { type: "complete", child: next.state.child, turn: next.state.turn }, - ]); -}); -test("completion and abort require outcomes and terminal states ignore late events", () => { - const state = awaiting(); - const next = decideTurn(state, { - type: "model_settled", - child: state.child, - result: { kind: "succeeded", value: admitted.parse({ kind: "answer", text: "done" }) }, - }).state; - expect(next).toMatchObject({ status: "done", record: { outcome: { kind: "completed" } } }); - expect(decideTurn(next, { type: "abort" }).state).toBe(next); - expect(decideTurn(state, { type: "abort" })).toMatchObject({ - state: { status: "done", record: { outcome: { kind: "aborted" } } }, - commands: [{ type: "cancel", child: state.child }], - }); -}); -test("admission rejects empty batches, duplicate calls and unknown names", () => { - for (const result of [ - { kind: "tools", text: "", calls: [] }, - { - kind: "tools", - text: "", - calls: [ - { id: "a", name: "search", args: {} }, - { id: "a", name: "search", args: {} }, - ], - }, - { kind: "tools", text: "", calls: [{ id: "a", name: "unknown", args: {} }] }, - { kind: "handoff", text: "", agent: "unknown" }, - ]) - expect(() => admitted.parse(result)).toThrow(); -}); diff --git a/packages/core-agent/agent/agent-fsm.ts b/packages/core-agent/agent/agent-fsm.ts deleted file mode 100644 index 40c99cf1..00000000 --- a/packages/core-agent/agent/agent-fsm.ts +++ /dev/null @@ -1,366 +0,0 @@ -import type { z } from "zod"; - -import { defineMachine, stay, type Decision } from "../fsm/fsm.ts"; -import type { Continuation } from "../providers/types.ts"; -import type { CompletionUsage } from "../providers/usage.ts"; -import { validatePermissionDecisions, type PermissionDecisions } from "./permissions.ts"; -import type { BatchOutcome } from "./tool-batch.ts"; -import { - appendMessage, - CompletionSchema, - failure, - PositiveStepsSchema, - ref, - StepsSchema, - type ActorId, - type AgentId, - type ChildRef, - type Completion, - type Failure, - type Outcome, - type Ref, - type Result, - type Steps, - type TurnData, - type TurnRecord, -} from "./types.ts"; - -/** - * Builds the schema that admits a model completion against the live registry: a handoff must name a - * known agent and every tool call must name a permitted tool. "Admitted" here means checked against - * the registry (not an input receipt). The completion operation parses its output with this schema - * before the turn sees it as a `model_settled` event. - * @param agents Agent ids a handoff may target. - * @param tools Tool names the step may call. - */ -export const admittedCompletionSchema = (agents: ReadonlySet, tools: ReadonlySet) => - CompletionSchema.refine( - (result) => result.kind !== "handoff" || agents.has(result.agent), - "Unknown handoff agent", - ) - .refine( - (result) => result.kind !== "tools" || result.calls.every((call) => tools.has(call.name)), - "Unpermitted tool", - ) - .brand<"AdmittedCompletion">(); - -/** A step's completion that passed {@link admittedCompletionSchema}; the only completion the turn machine accepts. */ -export type AdmittedCompletion = z.infer>; - -/** - * State of one turn: from the user prompt until the model stops. Each active state holds exactly one - * `child`: the operation the turn is waiting on (not a child session). A step runs in - * `awaiting_model` (prompt projection and LLM call as one operation), then ends the turn, hands - * off, or runs its tool batch (optionally after `awaiting_permission`) before the next step starts. - */ -export type TurnState = - /** No prompt yet. `steps` is the allowance the turn will start with. */ - | Readonly<{ status: "idle"; id: ActorId; agent: AgentId; steps: Steps }> - /** - * The step's prompt is projected and its LLM call is in flight, in one host operation. User - * input here is a barge-in that cancels the call and re-issues the step; a handoff's first step - * starts here too, with `turn.view` naming the predecessor and where its messages begin. - */ - | Readonly<{ status: "awaiting_model"; turn: TurnData; child: Ref<"completion"> }> - /** - * The settled step proposed tool calls that need user permission. `batch` is the ref the tool - * batch will use once permission is granted. - */ - | Readonly<{ - status: "awaiting_permission"; - turn: TurnData; - child: Ref<"permission">; - batch: Ref<"batch">; - completion: Extract; - }> - /** The step's tool batch is running; the next step starts only after the whole batch settles. */ - | Readonly<{ status: "executing_tools"; turn: TurnData; child: Ref<"batch"> }> - /** - * Abort arrived while tools ran; waiting for the batch to settle so finished results are kept. The - * turn then ends aborted. - */ - | Readonly<{ status: "cancelling_tools"; turn: TurnData; child: Ref<"batch"> }> - /** The turn ended; `record` holds its messages and outcome. */ - | Readonly<{ status: "done"; record: TurnRecord }>; - -/** - * Input to the turn machine: user input, abort, or the settlement of the turn's current child - * operation. Settlement events whose `child` is not the current child are ignored as late, and - * events without an edge in the current state are ignored. - */ -export type TurnEvent = - /** - * User text. When idle it starts the turn. While awaiting the model it is a barge-in: the child - * is cancelled, the text is appended and the step is prepared again. It has no edge while - * permission or tools are pending. - */ - | { type: "user"; text: string } - /** Ends the turn as aborted. While tools run it first waits for the batch to settle (`cancelling_tools`). */ - | { type: "abort" } - /** - * The step's prompt projection and LLM call settled (a settled step). An answer ends the turn - * completed; tool calls start the batch or a permission request; a handoff starts the successor - * agent's own step. - * @property provider Provider that produced this output, when the policy named one. - * @property model Application model name that produced this output. - * @property continuation Provider continuation payload (thinking signatures), not the next step. - * @property permissionRequired Ask the user before running the proposed tool calls. - */ - | { - type: "model_settled"; - child: Ref<"completion">; - result: Result; - provider?: string; - model?: string; - continuation?: Continuation; - usage?: CompletionUsage; - permissionRequired?: true; - } - /** - * The user answered the permission request. A `cancelled` decision ends the turn aborted; refused - * calls (`reject_once`) get a permission-refused tool result and the batch runs for every call, - * approved or refused. - */ - | { type: "permission_settled"; child: Ref<"permission">; result: Result } - /** - * The step's whole tool batch settled. Collected results are appended as tool messages; a - * succeeded batch starts the next step, a failed or cancelled one ends the turn. - */ - | { type: "batch_settled"; child: Ref<"batch">; outcome: BatchOutcome } - /** Dispatching or running the current child failed outside its result; ends the turn failed. */ - | { type: "failed"; child: ChildRef; error: Failure }; - -/** - * Effect the turn asks its host to perform after a decision commits. Each command that names a - * `child` starts that child operation; its settlement comes back as a {@link TurnEvent}. - */ -export type TurnCommand = - /** - * Project the step's prompt and make its LLM call; answers with `model_settled`. `turn.view` - * tells the host whether this is a handoff's first step. - */ - | { type: "complete"; child: Ref<"completion">; turn: TurnData } - /** Ask the user to permit the step's tool calls; answers with `permission_settled`. */ - | { - type: "request_permission"; - child: Ref<"permission">; - batch: Ref<"batch">; - completion: Extract; - } - /** - * Run the step's tool batch; answers with `batch_settled`. `permission` names the permission - * operation whose grants apply, when one ran. - */ - | { - type: "run_tools"; - child: Ref<"batch">; - permission?: Ref<"permission">; - completion: Extract; - } - /** Cancel a child operation the turn no longer waits for (abort, failure or barge-in). */ - | { type: "cancel"; child: ChildRef }; - -type Active = Exclude; - -type D = Decision; - -function done(turn: TurnData, outcome: Outcome): D { - return { - state: { status: "done", record: { agent: turn.agent, messages: turn.messages, outcome } }, - commands: [], - }; -} - -function prepare(turn: TurnData): D { - if (turn.steps === 0) return done(turn, { kind: "exhausted" }); - const next = { - ...turn, - steps: StepsSchema.parse(PositiveStepsSchema.parse(turn.steps) - 1), - generation: turn.generation + 1, - }; - const child = ref("completion", `${turn.id}/${next.generation}`); - return { - state: { status: "awaiting_model", turn: next, child }, - commands: [{ type: "complete", child, turn: next }], - }; -} - -function abort(state: Active): D { - return { - ...done(state.turn, { - kind: "aborted", - reason: failure({ - message: "User cancelled the active turn", - classification: "cancelled", - operation: { ...state.child, turnId: state.turn.id }, - }), - }), - commands: [{ type: "cancel", child: state.child }], - }; -} - -function fail(state: Active, event: { child: ChildRef; error: Failure }): D { - if (event.child.id !== state.child.id || event.child.kind !== state.child.kind) - return stay(state); - return { - ...done(state.turn, { kind: "failed", error: event.error }), - commands: [{ type: "cancel", child: state.child }], - }; -} - -function resultFailure(turn: TurnData, result: Exclude, { kind: "succeeded" }>): D { - return done( - turn, - result.kind === "failed" ? { kind: "failed", error: result.error } : { kind: "aborted" }, - ); -} - -function bargeIn(state: Active, event: { text: string }): D { - const next = prepare(appendMessage(state.turn, { role: "user", text: event.text })); - return { ...next, commands: [{ type: "cancel", child: state.child }, ...next.commands] }; -} - -/** - * The turn machine: a pure transition from a {@link TurnState} and {@link TurnEvent} to the next - * state and the {@link TurnCommand}s to dispatch. Each step consumes one of the turn's steps when the - * model is called; a turn with no steps left at a step boundary ends `exhausted`. - */ -export const decideTurn = defineMachine({ - idle: { - user: (state, event) => - prepare({ - id: state.id, - generation: 0, - agent: state.agent, - steps: state.steps, - messages: [{ role: "user", text: event.text }], - view: { kind: "history" }, - }), - abort: (state) => ({ - state: { - status: "done", - record: { agent: state.agent, messages: [], outcome: { kind: "aborted" } }, - }, - commands: [], - }), - }, - awaiting_model: { - user: bargeIn, - abort, - failed: fail, - model_settled: (state, event) => { - if (event.child.id !== state.child.id) return stay(state); - if (event.result.kind !== "succeeded") return resultFailure(state.turn, event.result); - const result = event.result.value; - const turn = appendMessage( - state.turn, - result.kind === "tools" - ? { role: "assistant", text: result.text, calls: result.calls } - : { role: "assistant", text: result.text }, - ); - if (result.kind === "answer") return done(turn, { kind: "completed" }); - if (result.kind === "handoff") - return prepare({ - ...turn, - agent: result.agent, - view: { kind: "handoff", from: turn.agent, at: turn.messages.length }, - }); - const next = { ...turn, generation: turn.generation + 1 }; - if (event.permissionRequired) { - const child = ref("permission", `${turn.id}/${next.generation}`); - const batch = ref("batch", `${turn.id}/${next.generation + 1}`); - return { - state: { status: "awaiting_permission", turn: next, child, batch, completion: result }, - commands: [{ type: "request_permission", child, batch, completion: result }], - }; - } - const child = ref("batch", `${turn.id}/${next.generation}`); - return { - state: { status: "executing_tools", turn: next, child }, - commands: [{ type: "run_tools", child, completion: result }], - }; - }, - }, - awaiting_permission: { - abort, - failed: fail, - permission_settled: (state, event) => { - if (event.child.id !== state.child.id) return stay(state); - if (event.result.kind !== "succeeded") return resultFailure(state.turn, event.result); - const decisions = validatePermissionDecisions(state.completion.calls, event.result.value); - const cancelled = decisions.find((entry) => entry.decision === "cancelled"); - if (cancelled) - return done(state.turn, { - kind: "aborted", - reason: failure({ - message: "User cancelled permission request", - classification: "cancelled", - operation: { ...state.child, turnId: state.turn.id, callId: cancelled.callId }, - }), - }); - return { - state: { - status: "executing_tools", - turn: { ...state.turn, generation: state.turn.generation + 1 }, - child: state.batch, - }, - commands: [ - { - type: "run_tools", - child: state.batch, - permission: state.child, - completion: state.completion, - }, - ], - }; - }, - }, - executing_tools: { - abort: (state) => ({ - state: { ...state, status: "cancelling_tools" }, - commands: [{ type: "cancel", child: state.child }], - }), - failed: fail, - batch_settled: (state, event) => { - if (event.child.id !== state.child.id) return stay(state); - const turn = event.outcome.results.reduce( - (current, result) => - appendMessage(current, { - role: "tool", - text: result.text, - callId: result.callId, - ...(result.parts ? { parts: result.parts } : {}), - }), - state.turn, - ); - return event.outcome.kind === "succeeded" - ? prepare(turn) - : resultFailure(turn, event.outcome); - }, - }, - cancelling_tools: { - failed: fail, - batch_settled: (state, event) => { - if (event.child.id !== state.child.id) return stay(state); - const turn = event.outcome.results.reduce( - (current, result) => - appendMessage(current, { - role: "tool", - text: result.text, - callId: result.callId, - ...(result.parts ? { parts: result.parts } : {}), - }), - state.turn, - ); - return done(turn, { - kind: "aborted", - reason: failure({ - message: "User cancelled the active tool batch", - classification: "cancelled", - operation: { ...state.child, turnId: turn.id }, - }), - }); - }, - }, - done: {}, -}); diff --git a/packages/core-agent/agent/agent-projection.test.ts b/packages/core-agent/agent/agent-projection.test.ts deleted file mode 100644 index 6de7e015..00000000 --- a/packages/core-agent/agent/agent-projection.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { projectConversationPrompt } from "./prompt.ts"; -import { context as turnData } from "./test-support.ts"; -import { AgentIdSchema, MessagesSchema, type AgentMessage, type Outcome } from "./types.ts"; - -const exchange = () => - MessagesSchema.parse([ - { - role: "assistant", - text: "searching", - calls: [ - { id: "a", name: "search", args: {} }, - { id: "b", name: "search", args: {} }, - ], - }, - { role: "tool", callId: "a", text: "found" }, - ]); -const agent = { model: "local", tools: [] }; -const project = (messages: readonly AgentMessage[], outcome: Outcome) => - projectConversationPrompt({ - log: [{ agent: AgentIdSchema.parse("writer"), messages, outcome }], - turn: turnData(), - agent, - }); - -test("missing results in completed history or current turns raise errors", () => { - expect(() => project(exchange(), { kind: "completed" })).toThrow( - "logged turn 1: missing results for b", - ); - expect(() => - projectConversationPrompt({ log: [], turn: { ...turnData(), messages: exchange() }, agent }), - ).toThrow("current turn: missing results for b"); -}); - -test("interrupted tails retain matched results without altering source records", () => { - for (const outcome of [ - { kind: "aborted" }, - { kind: "exhausted" }, - { kind: "failed", error: { message: "offline" } }, - ] as const) { - const messages = exchange(); - const projected = project(messages, outcome); - const assistant = projected[0]!; - expect(assistant.role === "assistant" && assistant.tool_calls?.map((call) => call.id)).toEqual([ - "a", - ]); - expect(projected[1]).toEqual({ role: "tool", tool_call_id: "a", content: "found" }); - const original = messages[0]!; - expect(original.role === "assistant" && original.calls?.length).toBe(2); - } -}); - -test("interruption does not excuse missing results in earlier exchanges", () => { - expect(() => - project([...exchange(), { role: "assistant", text: "finished" }], { kind: "aborted" }), - ).toThrow("missing results for b"); -}); - -test("orphan, unmatched and duplicate results raise projection errors", () => { - expect(() => - project(MessagesSchema.parse([{ role: "tool", callId: "a", text: "orphan" }]), { - kind: "aborted", - }), - ).toThrow("orphan result a"); - for (const callId of ["a", "unknown"]) { - expect(() => - project(MessagesSchema.parse([...exchange(), { role: "tool", callId, text: "bad" }]), { - kind: "aborted", - }), - ).toThrow("duplicate or unmatched"); - } -}); - -test("a handoff packet cannot conceal invalid source history", () => { - expect(() => - projectConversationPrompt({ - log: [], - turn: { ...turnData(), messages: exchange() }, - handoff: [{ role: "user", text: "summary" }], - agent, - }), - ).toThrow("current turn: missing results for b"); -}); diff --git a/packages/core-agent/agent/agent-runtime.test.ts b/packages/core-agent/agent/agent-runtime.test.ts deleted file mode 100644 index 1d6e5c6b..00000000 --- a/packages/core-agent/agent/agent-runtime.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { createAgentRuntime, defineTool, type RuntimeOptions } from "./agent-runtime.ts"; -import type { ChatCompletionRequest } from "./agent.ts"; -import { deferred, until } from "./test-support.ts"; -import { MessageSchema } from "./types.ts"; - -function harness(overrides: Partial = {}) { - const requests: Array<{ request: ChatCompletionRequest } & ReturnType>> = - []; - const runtime = createAgentRuntime({ - agent: "writer", - baseUrl: "http://localhost/v1", - steps: 6, - agents: new Map([ - ["writer", { model: "writer-model", systemPrompt: "Write.", tools: ["search"] }], - ["reviewer", { model: "reviewer-model", systemPrompt: "Review." }], - ]), - tools: new Map([ - [ - "search", - defineTool({ input: z.object({ query: z.number().optional() }), run: () => "result" }), - ], - ]), - complete: (request) => { - const result = deferred(); - requests.push({ request, ...result }); - return result.promise; - }, - ...overrides, - }); - return { runtime, requests, state: () => runtime.snapshot.conversation }; -} - -const answer = (text: string) => ({ kind: "answer", text }); - -const calls = (...ids: string[]) => ({ - kind: "tools", - text: "searching", - calls: ids.map((id) => ({ id, name: "search", args: {} })), -}); - -test("two turns retain history, immutable snapshots and fresh per-turn budgets", async () => { - const { runtime, requests, state } = harness(); - await runtime.fire({ type: "user", text: "first" }); - await until(() => requests.length === 1); - requests[0]!.resolve(answer("first answer")); - await until(() => state().log.length === 1); - const previous = state(); - expect(previous.turn.status === "idle" && Number(previous.turn.steps)).toBe(6); - await runtime.fire({ type: "user", text: "second" }); - await until(() => requests.length === 2); - expect(requests[1]!.request.messages).toEqual([ - { role: "system", content: "Write." }, - { role: "user", content: "first" }, - { role: "assistant", content: "first answer" }, - { role: "user", content: "second" }, - ]); - requests[1]!.resolve(answer("second answer")); - await until(() => state().log.length === 2); - expect(previous.log).toHaveLength(1); - expect(Object.isFrozen(previous.log[0]!.messages)).toBe(true); - expect(state().log.map((record) => record.outcome.kind)).toEqual(["completed", "completed"]); - expect(runtime.snapshot.children).toHaveLength(0); -}); - -test("tool child actors fan out once and resume only after the complete batch", async () => { - const executions: Array<{ signal: AbortSignal } & ReturnType>> = []; - const { runtime, requests, state } = harness({ - tools: new Map([ - [ - "search", - defineTool({ - input: z.object({}), - run: (_, signal) => { - const response = deferred(); - executions.push({ signal, ...response }); - return response.promise; - }, - }), - ], - ]), - }); - await runtime.fire({ type: "user", text: "search" }); - await until(() => requests.length === 1); - requests[0]!.resolve(calls("a", "b")); - await until(() => executions.length === 2); - expect(runtime.snapshot.children.filter((child) => child.ref.kind === "tool")).toHaveLength(2); - expect(executions[0]!.signal).not.toBe(executions[1]!.signal); - executions[1]!.resolve({ value: 2 }); - await until( - () => runtime.snapshot.children.filter((child) => child.ref.kind === "tool").length === 1, - ); - expect(requests).toHaveLength(1); - await expect(runtime.fire({ type: "user", text: "too soon" })).rejects.toThrow( - "tools are active", - ); - executions[0]!.resolve("one"); - await until(() => requests.length === 2); - expect(requests[1]!.request.messages.slice(-2)).toEqual([ - { role: "tool", content: '{"value":2}', tool_call_id: "b" }, - { role: "tool", content: "one", tool_call_id: "a" }, - ]); - expect(executions).toHaveLength(2); - requests[1]!.resolve(answer("done")); - await until(() => state().log.length === 1); -}); - -test("handoff prepares a slim packet and starts the successor without further user input", async () => { - const { runtime, requests, state } = harness(); - await runtime.fire({ type: "user", text: "old" }); - await until(() => requests.length === 1); - await runtime.fire({ type: "user", text: "new" }); - await until(() => requests.length === 2); - requests[1]!.resolve({ kind: "handoff", text: "Review this draft", agent: "reviewer" }); - await until(() => requests.length === 3); - expect(requests[2]!.request.model).toBe("reviewer-model"); - expect(requests[2]!.request.messages).toEqual([ - { role: "system", content: "Review." }, - { role: "user", content: "new" }, - { role: "assistant", content: "Review this draft" }, - ]); - requests[2]!.resolve(answer("Approved")); - await until(() => state().log.length === 1); - expect(state().log[0]!.messages.map((message) => message.text)).toEqual([ - "old", - "new", - "Review this draft", - "Approved", - ]); -}); - -test("barge-in aborts real fetch once; its AbortError cannot stop the replacement", async () => { - const fetches: Array< - { signal: AbortSignal; aborts: number } & ReturnType> - > = []; - const { runtime, state } = harness({ - complete: undefined, - fetch: (async (_, init) => { - const response = deferred(); - const call = { signal: init!.signal!, aborts: 0, ...response }; - fetches.push(call); - call.signal.addEventListener("abort", () => { - call.aborts++; - response.reject(new DOMException("cancelled", "AbortError")); - }); - return response.promise; - }) as typeof fetch, - }); - await runtime.fire({ type: "user", text: "first" }); - await until(() => fetches.length === 1); - await runtime.fire({ type: "user", text: "replacement" }); - await until(() => fetches.length === 2); - expect(fetches[0]!.aborts).toBe(1); - expect(fetches[1]!.signal.aborted).toBe(false); - fetches[1]!.resolve(Response.json({ choices: [{ message: { content: "answer" } }] })); - await until(() => state().log.length === 1); - expect(state().log[0]!.outcome.kind).toBe("completed"); -}); - -test("late success and failure from cancelled requests cannot alter replacement work", async () => { - const { runtime, requests, state } = harness(); - await runtime.fire({ type: "user", text: "first" }); - await until(() => requests.length === 1); - await runtime.fire({ type: "user", text: "replacement" }); - await until(() => requests.length === 2); - requests[0]!.resolve(answer("stale")); - requests[1]!.resolve(answer("current")); - await until(() => state().log.length === 1); - expect(state().log[0]!.messages.at(-1)?.text).toBe("current"); - await runtime.fire({ type: "user", text: "cancel me" }); - await until(() => requests.length === 3); - await runtime.fire({ type: "abort" }); - await runtime.fire({ type: "user", text: "next turn" }); - await until(() => requests.length === 4); - requests[2]!.reject(new Error("late failure")); - requests[3]!.resolve(answer("done")); - await until(() => state().log.length === 3); - expect(state().log.map((record) => record.outcome.kind)).toEqual([ - "completed", - "aborted", - "completed", - ]); -}); - -test("budgets cover handoff, post-tool and barge-in launches without extra requests", async () => { - for (const result of [calls("a"), { kind: "handoff", text: "review", agent: "reviewer" }]) { - const { runtime, requests, state } = harness({ steps: 1 }); - await runtime.fire({ type: "user", text: "work" }); - await until(() => requests.length === 1); - requests[0]!.resolve(result); - await until(() => state().log.length === 1); - expect(requests).toHaveLength(1); - expect(state().log[0]!.outcome.kind).toBe("exhausted"); - } - const { runtime, requests, state } = harness({ steps: 1 }); - await runtime.fire({ type: "user", text: "first" }); - await until(() => requests.length === 1); - await runtime.fire({ type: "user", text: "replacement" }); - await until(() => state().log.length === 1); - expect(state().log[0]!.outcome.kind).toBe("exhausted"); - expect(requests).toHaveLength(1); - const zero = harness({ steps: 0 }); - await zero.runtime.fire({ type: "user", text: "no budget" }); - expect(zero.requests).toHaveLength(0); - expect(zero.state().log[0]!.outcome.kind).toBe("exhausted"); -}); - -test("Zod rejects malformed, mixed and unpermitted completions before tool execution", async () => { - let ran = 0; - for (const result of [ - null, - { text: "missing discriminant" }, - { kind: "tools", text: "", calls: [] }, - calls("a", "a"), - { kind: "handoff", text: "", agent: "missing" }, - { kind: "tools", text: "", calls: [{ id: "a", name: "missing", args: {} }] }, - { ...calls("a"), agent: "reviewer" }, - ]) { - const { runtime, requests, state } = harness({ - tools: new Map([ - [ - "search", - defineTool({ - input: z.object({}), - run: () => { - ran++; - return "ok"; - }, - }), - ], - ]), - }); - await runtime.fire({ type: "user", text: "work" }); - await until(() => requests.length === 1); - requests[0]!.resolve(result); - await until(() => state().log.length === 1); - expect(state().log[0]!.outcome.kind).toBe("failed"); - } - expect(ran).toBe(0); -}); - -test("tool argument validation fails its actor before invoking the tool", async () => { - let ran = false; - const { runtime, requests, state } = harness({ - tools: new Map([ - [ - "search", - defineTool({ - input: z.object({ query: z.string() }), - run: ({ query }) => { - ran = true; - return query; - }, - }), - ], - ]), - }); - await runtime.fire({ type: "user", text: "work" }); - await until(() => requests.length === 1); - requests[0]!.resolve(calls("a")); - await until(() => state().log.length === 1); - expect(ran).toBe(false); - expect(state().log[0]!.outcome.kind).toBe("failed"); -}); - -test("tool failure cancels siblings and preserves results already settled", async () => { - const executions: Array<{ signal: AbortSignal } & ReturnType>> = []; - const { runtime, requests, state } = harness({ - tools: new Map([ - [ - "search", - defineTool({ - input: z.object({}), - run: (_, signal) => { - const result = deferred(); - executions.push({ signal, ...result }); - return result.promise; - }, - }), - ], - ]), - }); - await runtime.fire({ type: "user", text: "work" }); - await until(() => requests.length === 1); - requests[0]!.resolve(calls("a", "b", "c")); - await until(() => executions.length === 3); - executions[0]!.resolve("found"); - await until( - () => runtime.snapshot.children.filter((child) => child.ref.kind === "tool").length === 2, - ); - executions[1]!.reject(new Error("offline")); - await until(() => state().log.length === 1); - expect(executions[2]!.signal.aborted).toBe(true); - expect(state().log[0]!.outcome).toMatchObject({ kind: "failed", error: { message: "offline" } }); - expect(state().log[0]!.messages.at(-1)).toEqual( - MessageSchema.parse({ role: "tool", callId: "a", text: "found" }), - ); - await runtime.fire({ type: "user", text: "again" }); - await until(() => requests.length === 2); - executions[2]!.resolve("late"); - requests[1]!.resolve(answer("recovered")); - await until(() => state().log.length === 2); - expect(state().log[1]!.outcome.kind).toBe("completed"); -}); - -test("explicit tool-batch abort preserves partial results and cancels every remaining child", async () => { - const executions: Array<{ signal: AbortSignal } & ReturnType>> = []; - const { runtime, requests, state } = harness({ - tools: new Map([ - [ - "search", - defineTool({ - input: z.object({}), - run: (_, signal) => { - const result = deferred(); - executions.push({ signal, ...result }); - return result.promise; - }, - }), - ], - ]), - }); - await runtime.fire({ type: "user", text: "work" }); - await until(() => requests.length === 1); - requests[0]!.resolve(calls("a", "b")); - await until(() => executions.length === 2); - executions[0]!.resolve("found"); - await until( - () => runtime.snapshot.children.filter((child) => child.ref.kind === "tool").length === 1, - ); - await runtime.fire({ type: "abort" }); - await until(() => state().log.length === 1 && runtime.snapshot.children.length === 0); - expect(executions[1]!.signal.aborted).toBe(true); - expect(state().log[0]!.messages.at(-1)).toEqual( - MessageSchema.parse({ role: "tool", callId: "a", text: "found" }), - ); - expect(state().log[0]!.outcome.kind).toBe("aborted"); -}); - -test("projection and handoff failures are actor outcomes and preserve a valid terminal record", async () => { - for (const overrides of [ - { - complete: () => { - throw new Error("offline"); - }, - }, - { - projectPrompt: () => { - throw new Error("projection failed"); - }, - }, - { projectPrompt: () => "invalid prompt" }, - ]) { - const { runtime, state } = harness(overrides); - await runtime.fire({ type: "user", text: "work" }); - await until(() => state().log.length === 1); - expect(state().log[0]!.outcome.kind).toBe("failed"); - } - const { runtime, requests, state } = harness({ - projectHandoff: () => { - throw new Error("packet failed"); - }, - }); - await runtime.fire({ type: "user", text: "work" }); - await until(() => requests.length === 1); - requests[0]!.resolve({ kind: "handoff", text: "draft", agent: "reviewer" }); - await until(() => state().log.length === 1); - expect(state().log[0]!.outcome).toMatchObject({ - kind: "failed", - error: { message: "packet failed" }, - }); -}); - -test("cancelling prompt preparation prevents a late projection from launching HTTP", async () => { - const projection = deferred(); - let signal!: AbortSignal; - const { runtime, requests, state } = harness({ - projectPrompt: (_, current) => { - signal = current; - return projection.promise; - }, - }); - await runtime.fire({ type: "user", text: "work" }); - await until(() => Boolean(signal)); - await runtime.fire({ type: "abort" }); - projection.resolve([]); - await until(() => runtime.snapshot.children.length === 0); - expect(signal.aborted).toBe(true); - expect(requests).toHaveLength(0); - expect(state().log[0]!.outcome.kind).toBe("aborted"); -}); - -test("public inputs cannot forge child messages or bypass validation", async () => { - const { runtime, state } = harness(); - const before = state(); - await expect( - runtime.fire({ type: "child", turnId: "turn/1", event: { type: "abort" } }), - ).rejects.toThrow(); - await expect(runtime.fire({ type: "user", text: 42 })).rejects.toThrow(); - expect(state()).toBe(before); - expect(() => harness({ steps: -1 })).toThrow(); -}); - -test("runtime copies tool definitions so caller mutation cannot change admitted execution", async () => { - const definition = { - parameters: { type: "object" }, - parseInput: async (value: unknown) => value, - run: () => "original", - }; - const registry = new Map([["search", definition]]); - const { runtime, requests, state } = harness({ tools: registry }); - definition.run = () => "mutated"; - definition.parameters.type = "string"; - registry.clear(); - await runtime.fire({ type: "user", text: "work" }); - await until(() => requests.length === 1); - expect(requests[0]!.request.tools?.[0]?.function.parameters).toEqual({ type: "object" }); - requests[0]!.resolve(calls("a")); - await until(() => requests.length === 2); - expect(requests[1]!.request.messages.at(-1)).toEqual({ - role: "tool", - content: "original", - tool_call_id: "a", - }); - requests[1]!.resolve(answer("done")); - await until(() => state().log.length === 1); -}); diff --git a/packages/core-agent/agent/agent-runtime.ts b/packages/core-agent/agent/agent-runtime.ts deleted file mode 100644 index 4898c16b..00000000 --- a/packages/core-agent/agent/agent-runtime.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { z } from "zod"; - -import type { EffectEmitter } from "../effects/index.ts"; -import { Actor, freeze } from "../fsm/fsm.ts"; -import { createHost, type StreamUpdateSink, type ToolUpdateSink } from "../host/host.ts"; -import { - copyRegistries, - type AgentDefinition, - type PermissionPort, - type Tool, -} from "../host/ports.ts"; -import { - decideConversation, - initialConversation, - type ConversationCommand, - type ConversationEvent, - type ConversationState, - type SessionReply, - type SessionRequest, -} from "./agent-conversation.ts"; -import type { TurnEvent } from "./agent-fsm.ts"; -import { createChatCompletion, type ChatCompletionRequest } from "./agent.ts"; -import type { OperationState } from "./operation-actor.ts"; -import { - parseSessionContext, - projectConversationPrompt, - type ProjectedPrompt, - type PromptInput, -} from "./prompt.ts"; -import type { BatchState } from "./tool-batch.ts"; -import { - ActorIdSchema, - AgentIdSchema, - failure, - MessagesSchema, - SessionIdSchema, - StepsSchema, - UserEventSchema, - type ActorId, - type AgentMessage, - type ChildRef, - type TurnData, -} from "./types.ts"; - -export { projectConversationPrompt } from "./prompt.ts"; -export type { PromptInput } from "./prompt.ts"; - -export { defineTool } from "../host/ports.ts"; - -export type { Tool, AgentDefinition }; -export type { - HostToolNotification, - ToolUpdateSink, - HostStreamNotification, - StreamUpdateSink, -} from "../host/host.ts"; -export type { PermissionPort, PermissionRequest, ToolKind, ToolLocation } from "../host/ports.ts"; -/** Configuration for {@link createAgentRuntime}. The agent and tool maps are copied at creation. */ -export type RuntimeOptions = { - /** Id of the agent that runs the first turn; must be a key of `agents`. */ - agent: string; - agents: ReadonlyMap; - tools?: ReadonlyMap; - toolUpdate?: ToolUpdateSink; - streamUpdate?: StreamUpdateSink; - /** When set, tool calls ask this port for permission; when omitted, permissions are off. */ - requestPermission?: PermissionPort; - /** Step allowance of each turn (LLM calls per turn). */ - steps: number; - /** http(s) base URL of the chat completions endpoint. */ - baseUrl: string; - apiKey?: string; - /** Fetch used by the default completion adapter. */ - fetch?: typeof fetch; - /** Subscriber for this runtime's effect events; combined with the default diagnostics subscriber. */ - effects?: EffectEmitter; - /** Adapters return untrusted data; the completion actor parses and admits it. */ - complete?: (request: ChatCompletionRequest) => unknown | Promise; - /** Prompt projection for each step; defaults to {@link projectConversationPrompt}. */ - projectPrompt?: (input: PromptInput, signal: AbortSignal) => unknown | Promise; - /** Builds the successor agent's context on a handoff. */ - projectHandoff?: ( - input: PromptInput & { from: string; to: string }, - signal: AbortSignal, - ) => unknown | Promise; -}; -/** Snapshot of one live child operation of a turn (not a child session): an operation or a tool batch. */ -export type ChildSnapshot = Readonly<{ - ref: ChildRef; - state: OperationState | BatchState; -}>; -/** Frozen view of the conversation state and its live child operations. */ -export type RuntimeSnapshot = Readonly<{ - conversation: ConversationState; - children: readonly ChildSnapshot[]; -}>; -/** An in-memory conversation driven by user events; turns run through the host in the background. */ -export type AgentRuntime = { - /** Current snapshot, rebuilt on each read. */ - readonly snapshot: RuntimeSnapshot; - /** - * Sends a user event (prompt text or abort) to the conversation. - * Resolves once the event is decided and its commands are dispatched, not when the turn ends. - * @throws Rejects when `event` fails {@link UserEventSchema} or the conversation refuses it (for - * example user input while tools are pending). - */ - fire(event: unknown): Promise; - /** - * Resolves at the next turn boundary with an independent runtime for a new session that inherits - * this session's history. Resolves immediately when no turn is active. - */ - fork(): Promise; - /** - * Like {@link AgentRuntime.fork}, but the new session starts with an empty history and `context` - * as its session context. "Replacement" here is compaction context, not barge-in. - * @throws Rejects when `context` is not a valid session context. - */ - compact(context: unknown): Promise; -}; - -/** - * Creates a runtime for a new root session with an idle first turn. - * @throws When `agent` is not in `agents`, `steps` is not a valid step count or `baseUrl` is not - * an http(s) URL. - */ -export function createAgentRuntime(options: RuntimeOptions): AgentRuntime { - const agentId = AgentIdSchema.parse(options.agent); - const steps = StepsSchema.parse(options.steps); - const baseUrl = z.url({ protocol: /^https?$/ }).parse(options.baseUrl); - const { agents, tools } = copyRegistries(options); - if (!agents.has(agentId)) throw new Error(`Unknown agent: ${agentId}`); - const apiKey = options.apiKey; - const fetcher = options.fetch; - const projectHandoff = - options.projectHandoff ?? - ((input: PromptInput & { from: string; to: string }) => - [ - input.turn.messages.findLast((message) => message.role === "user"), - input.turn.messages.at(-1), - ].filter((message): message is AgentMessage => message !== undefined)); - const toolUpdate = options.toolUpdate; - const streamUpdate = options.streamUpdate; - const requestPermission = options.requestPermission; - const effects = options.effects; - const complete = - options.complete ?? - ((request: ChatCompletionRequest) => createChatCompletion(request, fetcher)); - const project = options.projectPrompt ?? projectConversationPrompt; - /** - * Prompt projection for the host's step. On a handoff step the packet is built by - * `projectHandoff` over the messages up to `turn.view.at`; the packet plus the messages added - * since are passed to `project` as `input.handoff`, so the projection option applies to every step. - * This runtime binds no provider, so the input has no target and no part becomes a pointer. - */ - const projectPrompt = async ( - input: PromptInput, - signal: AbortSignal, - ): Promise => { - const view = input.turn.view; - if (view.kind !== "handoff") return { messages: await project(input, signal), pointers: [] }; - const packet = MessagesSchema.parse( - await projectHandoff( - { - ...input, - turn: { - ...input.turn, - messages: input.turn.messages.slice(0, view.at), - view: { kind: "history" }, - }, - from: view.from, - to: input.turn.agent, - }, - signal, - ), - ); - return { - messages: await project( - { ...input, handoff: [...packet, ...input.turn.messages.slice(view.at)] }, - signal, - ), - pointers: [], - }; - }; - function build(initial: ConversationState): AgentRuntime { - const replies = new Map void>(); - - let conversation: Actor; - const input = (turn: TurnData): PromptInput => ({ - context: conversation.snapshot.context, - log: conversation.snapshot.log, - turn, - agent: agents.get(turn.agent)!, - }); - const post = (turnId: ActorId, event: TurnEvent) => { - // Only validated, typed child outcomes enter this private mailbox. There is no journal - // fold here to derive the permission route from a policy, so it is derived the same way - // `requestPermission` gates the host: configured means every tool batch asks first. - const enriched = - event.type === "model_settled" && - event.result.kind === "succeeded" && - event.result.value.kind === "tools" && - requestPermission - ? { ...event, permissionRequired: true as const } - : event; - void conversation.send({ type: "child", turnId, event: enriched }); - }; - const host = createHost( - { - agents, - tools, - sessionId: initial.sessionId, - requestPermission, - complete: (request, signal) => complete({ ...request, baseUrl, apiKey, signal }), - effects, - }, - { - turn: post, - toolUpdate, - streamUpdate, - tool: (outcome) => host.releaseTool(outcome), - }, - ); - const execute = (effect: ConversationCommand): undefined => { - if (effect.type === "reply") { - const reply = replies.get(effect.requestId); - replies.delete(effect.requestId); - reply?.(effect.result); - } else - host.dispatch(effect, { - prompt: "turn" in effect.command ? input(effect.command.turn) : undefined, - projectPrompt, - }); - return undefined; - }; - conversation = new Actor( - initial, - decideConversation, - execute, - (command, error) => ({ type: "dispatch_failed", command, error: failure(error) }), - ); - const snapshot = (): RuntimeSnapshot => - freeze({ - conversation: conversation.snapshot, - children: host.snapshot, - }); - const enqueue = (request: SessionRequest): Promise => - new Promise((resolve, reject) => { - replies.set(request.id, resolve); - void conversation.send({ type: "request", request }).catch((error) => { - replies.delete(request.id); - reject(error); - }); - }); - const requestId = () => - ActorIdSchema.parse(`${initial.sessionId}/request/${crypto.randomUUID()}`); - const branch = async (request: SessionRequest): Promise => { - const reply = await enqueue(request); - return build(reply.state); - }; - return { - get snapshot() { - return snapshot(); - }, - async fire(raw) { - await conversation.send(UserEventSchema.parse(raw)); - return snapshot(); - }, - fork: () => - branch({ - kind: "fork", - id: requestId(), - sessionId: SessionIdSchema.parse(crypto.randomUUID()), - }), - async compact(raw) { - const context = parseSessionContext(raw); - return branch({ - kind: "compact", - id: requestId(), - sessionId: SessionIdSchema.parse(crypto.randomUUID()), - context, - }); - }, - }; - } - return build(initialConversation(agentId, steps)); -} diff --git a/packages/core-agent/agent/agent.test.ts b/packages/core-agent/agent/agent.test.ts deleted file mode 100644 index 6b1f73fe..00000000 --- a/packages/core-agent/agent/agent.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { createChatCompletion } from "./agent.ts"; - -test("sends a chat completion request and returns the assistant message", async () => { - let receivedUrl = ""; - let receivedInit: RequestInit | undefined; - - const result = await createChatCompletion( - { - baseUrl: "http://host.docker.internal:8000/v1/", - model: "local-model", - apiKey: "secret", - messages: [ - { role: "system", content: "Be concise." }, - { role: "user", content: "Hello" }, - ], - temperature: 0.2, - }, - (async (input, init) => { - receivedUrl = String(input); - receivedInit = init; - return Response.json({ - choices: [{ message: { role: "assistant", content: "Hi there." } }], - }); - }) as typeof fetch, - ); - - expect(receivedUrl).toBe("http://host.docker.internal:8000/v1/chat/completions"); - expect(receivedInit?.method).toBe("POST"); - expect(receivedInit?.headers).toEqual({ - "Content-Type": "application/json", - Authorization: "Bearer secret", - }); - expect(JSON.parse(String(receivedInit?.body))).toEqual({ - model: "local-model", - messages: [ - { role: "system", content: "Be concise." }, - { role: "user", content: "Hello" }, - ], - temperature: 0.2, - }); - expect(result).toEqual({ kind: "answer", text: "Hi there." }); -}); - -test("includes the API error body when the completion request fails", async () => { - await expect( - createChatCompletion( - { - baseUrl: "http://localhost:8000/v1", - model: "local-model", - messages: [{ role: "user", content: "Hello" }], - }, - (async (_input, _init) => - new Response("model is unavailable", { status: 503 })) as typeof fetch, - ), - ).rejects.toThrow("OpenAI-compatible API request failed (503): model is unavailable"); -}); - -test("passes the signal to fetch and decodes tool-only responses", async () => { - const controller = new AbortController(); - let receivedSignal: AbortSignal | null | undefined; - const result = await createChatCompletion( - { - baseUrl: "http://localhost/v1", - model: "local", - messages: [], - signal: controller.signal, - }, - (async (_, init) => { - receivedSignal = init?.signal; - return Response.json({ - choices: [ - { - message: { - content: null, - tool_calls: [ - { - id: "call-1", - type: "function", - function: { name: "search", arguments: '{"query":"science"}' }, - }, - ], - }, - }, - ], - }); - }) as typeof fetch, - ); - expect(receivedSignal).toBe(controller.signal); - expect(result).toMatchObject({ - kind: "tools", - text: "", - calls: [{ id: "call-1", name: "search", args: { query: "science" } }], - }); -}); - -test("rejects malformed responses instead of completing an empty turn", async () => { - for (const response of [ - "invalid json", - "null", - JSON.stringify({ choices: [{ message: { content: null } }] }), - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { id: "1", type: "function", function: { name: "search", arguments: "invalid" } }, - ], - }, - }, - ], - }), - ]) { - await expect( - createChatCompletion( - { baseUrl: "http://localhost", model: "local", messages: [] }, - (async (_input, _init) => new Response(response)) as typeof fetch, - ), - ).rejects.toThrow(); - } -}); diff --git a/packages/core-agent/agent/agent.ts b/packages/core-agent/agent/agent.ts deleted file mode 100644 index 009bea89..00000000 --- a/packages/core-agent/agent/agent.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { z } from "zod"; - -import { openaiChat } from "../providers/openai-chat.ts"; -import { canonicalRequest, httpTransport } from "../providers/transport.ts"; -import { ContinuationSchema, ProviderSettingsSchema } from "../providers/types.ts"; -import { ContentPartsSchema, partsText } from "./content.ts"; -import { CompletionOwnerSchema, CompletionSchema, type Completion } from "./types.ts"; - -const ChatToolCallSchema = z - .strictObject({ - id: z.string().min(1), - type: z.literal("function"), - function: z.strictObject({ name: z.string().min(1), arguments: z.string() }), - }) - .readonly(); - -/** - * One message of a model request in OpenAI chat shape (`content`, `tool_calls`, - * `tool_call_id`), as prompt projection produces it. `canonicalRequest` turns it back into an - * `AgentMessage` before a provider profile encodes the request. When `parts` is present, - * `content` must equal the concatenation of its text parts. - */ -export const ChatMessageSchema = z - .discriminatedUnion("role", [ - z.strictObject({ - role: z.literal("system"), - content: z.string(), - parts: ContentPartsSchema.optional(), - }), - z.strictObject({ - role: z.literal("user"), - content: z.string(), - parts: ContentPartsSchema.optional(), - }), - z.strictObject({ - role: z.literal("assistant"), - content: z.string(), - owner: CompletionOwnerSchema.optional(), - parts: ContentPartsSchema.optional(), - tool_calls: z.array(ChatToolCallSchema).min(1).readonly().optional(), - }), - z.strictObject({ - role: z.literal("tool"), - content: z.string(), - tool_call_id: z.string().min(1), - parts: ContentPartsSchema.optional(), - }), - ]) - .refine( - (message) => !message.parts || message.content === partsText(message.parts), - "Message content must equal its text parts", - ) - .readonly(); - -/** One message of a model request in OpenAI chat shape. See {@link ChatMessageSchema}. */ -export type ChatMessage = z.infer; -/** A tool advertised to the model in OpenAI chat shape; `parameters` is its JSON Schema. */ -export const ChatToolSchema = z - .strictObject({ - type: z.literal("function"), - function: z.strictObject({ - name: z.string().min(1), - description: z.string().optional(), - parameters: z.record(z.string(), z.json()), - }), - }) - .readonly(); - -/** A tool advertised to the model. See {@link ChatToolSchema}. */ -export type ChatTool = z.infer; -/** - * The request a successful prepare operation produces for one step: the model, the projected - * messages, the advertised tools and the provider settings (provider, thinking, output limit, - * streaming) captured for that step. - */ -export const PreparedModelSchema = z - .strictObject({ - model: z.string().min(1), - messages: z.array(ChatMessageSchema).readonly(), - /** - * Provider continuation payloads (such as thinking signatures; not the next step) to send - * back with the assistant messages that own them. - */ - continuations: z.array(ContinuationSchema).readonly().optional(), - tools: z.array(ChatToolSchema).readonly().optional(), - temperature: z.number().finite().optional(), - ...ProviderSettingsSchema.unwrap().partial().shape, - /** - * Agents the model may hand off to; omitted means none. The handoff tool advertises them, - * and a bound provider rejects a handoff to any other agent. - */ - successors: z.array(z.string().min(1)).readonly().optional(), - }) - .readonly() - .brand<"PreparedModel">(); -/** The request prepared for one step. See {@link PreparedModelSchema}. */ -export type PreparedModel = z.infer; -/** - * Input to {@link createChatCompletion}: a prepared request plus the endpoint. - * `baseUrl` is an OpenAI-compatible base URL; trailing slashes and a trailing - * `/chat/completions` are removed. `apiKey` is sent as a Bearer token when not blank. - */ -export type ChatCompletionRequest = z.input & { - baseUrl: string; - apiKey?: string; - signal?: AbortSignal; -}; - -/** - * Runs one step against an OpenAI-compatible chat completions endpoint and decodes the reply. - * Legacy convenience adapter. New environments bind a versioned profile explicitly. - * - * A reply whose message carries a legacy `handoff` field decodes as a handoff completion. - * Unlike a bound provider, this function does not check handoff targets against `successors`. - * - * @param fetcher Replaces the global `fetch`. - * @throws Error when a legacy reply both calls tools and hands off. Also rejects when the request - * is invalid, the HTTP call fails or is aborted, or the reply is not a valid completion. - */ -export async function createChatCompletion( - request: ChatCompletionRequest, - fetcher: typeof fetch = fetch, -): Promise { - const { signal, baseUrl, apiKey, ...body } = request; - const prepared = PreparedModelSchema.parse(body); - const http = httpTransport( - { - baseUrl: baseUrl.replace(/\/+$/, "").replace(/\/chat\/completions$/, ""), - headers: apiKey?.trim() ? { Authorization: `Bearer ${apiKey.trim()}` } : {}, - fetch: fetcher, - }, - true, - ); - const response = await http( - openaiChat.encode(canonicalRequest(prepared)), - signal ?? new AbortController().signal, - ); - // Retained only for pre-profile custom servers; all new profiles use handoff_to. - const legacy = z - .object({ - choices: z - .array( - z.object({ - message: z.object({ - handoff: z.string().min(1).optional(), - content: z.string().nullish(), - tool_calls: z.array(z.unknown()).optional(), - }), - }), - ) - .min(1), - }) - .safeParse(response.body); - const message = legacy.success ? legacy.data.choices[0]?.message : undefined; - if (message?.handoff) { - if (message.tool_calls?.length) throw new Error("A completion cannot call tools and hand off"); - return CompletionSchema.parse({ - kind: "handoff", - text: message.content ?? "", - agent: message.handoff, - }); - } - return CompletionSchema.parse(openaiChat.decode(response).completion); -} diff --git a/packages/core-agent/agent/content.ts b/packages/core-agent/agent/content.ts deleted file mode 100644 index 34e0806c..00000000 --- a/packages/core-agent/agent/content.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { z } from "zod"; - -/** Largest attachment (blob) size that {@link BlobRefSchema} accepts, in bytes (8 MiB). */ -export const MAX_BLOB_BYTES = 8 * 1024 * 1024; -/** - * Content address of a stored attachment: the lowercase hex SHA-256 of its bytes. - * Equal bytes always get the same id; see {@link hashBlob}. - */ -export const BlobIdSchema = z - .string() - .regex(/^[a-f0-9]{64}$/) - .brand<"BlobId">(); -/** Content address of a stored attachment: lowercase hex SHA-256 of its bytes. */ -export type BlobId = z.infer; -/** Audio media types an attachment may declare. Part of {@link MediaKindSchema}. */ -export const AUDIO_MEDIA_KINDS = [ - "audio/wav", - "audio/mpeg", - "audio/mp3", - "audio/aiff", - "audio/aac", - "audio/ogg", - "audio/flac", - "audio/m4a", - "audio/l16", - "audio/opus", - "audio/alaw", - "audio/mulaw", - "audio/webm", -] as const; - -/** Media types an attachment may declare: text, Markdown, PNG, JPEG, PDF and the audio kinds. */ -export const MediaKindSchema = z.enum([ - "text/markdown", - "text/plain", - "image/png", - "image/jpeg", - "application/pdf", - ...AUDIO_MEDIA_KINDS, -]); -/** Media type of an attachment. See {@link MediaKindSchema}. */ -export type MediaKind = z.infer; -/** - * Reference to an attachment stored outside the message, carried in a `blob` content part. - * History and snapshots hold only this reference; the bytes are fetched by id with a - * {@link BlobResolver} while a completion request is encoded. - * - * `bytes` is the stored size in bytes (at most {@link MAX_BLOB_BYTES}); `name` is an optional - * display file name. - */ -export const BlobRefSchema = z - .strictObject({ - id: BlobIdSchema, - media: MediaKindSchema, - bytes: z.number().int().nonnegative().max(MAX_BLOB_BYTES), - name: z.string().min(1).optional(), - }) - .readonly(); -/** Reference to a stored attachment; the bytes stay outside history. See {@link BlobRefSchema}. */ -export type BlobRef = z.infer; -/** Stored metadata of a blob. Same shape as {@link BlobRefSchema}. */ -export const BlobMetaSchema = BlobRefSchema; -/** Stored metadata of a blob. Same shape as {@link BlobRef}. */ -export type BlobMeta = BlobRef; -/** - * Metadata a caller supplies when storing new blob bytes: the media type and an optional name. - * The id and size are derived from the bytes. - */ -export const BlobInputMetaSchema = BlobRefSchema.unwrap().pick({ media: true, name: true }); -/** - * One piece of message content: inline `text`, or a `blob` that references a stored attachment. - * A message's `text` must equal the concatenation of its text parts (see {@link partsText}). - */ -export const ContentPartSchema = z.discriminatedUnion("type", [ - z.strictObject({ type: z.literal("text"), text: z.string() }).readonly(), - z.strictObject({ type: z.literal("blob"), ref: BlobRefSchema }).readonly(), -]); -/** One piece of message content: inline text or a stored attachment reference. */ -export type ContentPart = z.infer; -/** Ordered content parts of one message. */ -export const ContentPartsSchema = z.array(ContentPartSchema).readonly(); -/** Joins the text parts in order, with no separator. Blob parts contribute nothing. */ -export function partsText(parts: readonly ContentPart[]) { - return parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); -} -/** Lists every blob reference in the messages' content parts, in order, duplicates included. */ -export function blobRefs( - messages: readonly { role: string; parts?: readonly ContentPart[] }[], -): BlobRef[] { - return messages.flatMap((message) => - (message.parts ?? []).flatMap((part) => (part.type === "blob" ? [part.ref] : [])), - ); -} -/** - * Returns the stored bytes for a blob id. - * Bytes are operation-local resources, never snapshot fields. - */ -export type BlobResolver = (id: BlobId) => Uint8Array; -/** Computes the {@link BlobId} of the bytes: their lowercase hex SHA-256 digest. */ -export function hashBlob(bytes: Uint8Array): BlobId { - return BlobIdSchema.parse(new Bun.CryptoHasher("sha256").update(bytes).digest("hex")); -} - -/** - * How well a projection target can read one media kind: `supported` renders the content directly, - * `unsupported` never can, `unknown` means the target's capabilities were not confirmed for it. - * `unsupported` and `unknown` both render as a pointer; see {@link renderBlobPointer}. - */ -export const MediaSupportSchema = z.enum(["supported", "unsupported", "unknown"]); -/** See {@link MediaSupportSchema}. */ -export type MediaSupport = z.infer; - -/** File extension a stored blob's {@link blobUri} uses, by media type. */ -const MEDIA_EXTENSION: Record = { - "text/markdown": "md", - "text/plain": "txt", - "image/png": "png", - "image/jpeg": "jpg", - "application/pdf": "pdf", - "audio/wav": "wav", - "audio/mpeg": "mp3", - "audio/mp3": "mp3", - "audio/aiff": "aiff", - "audio/aac": "aac", - "audio/ogg": "ogg", - "audio/flac": "flac", - "audio/m4a": "m4a", - "audio/l16": "l16", - "audio/opus": "opus", - "audio/alaw": "alaw", - "audio/mulaw": "mulaw", - "audio/webm": "webm", -}; - -/** - * The reference form of a stored blob: `blob://.`. The harness resolves it against - * whatever store holds the bytes; a model that reads it with a tool receives the content. - */ -export function blobUri(ref: Pick): string { - return `blob://${ref.id}.${MEDIA_EXTENSION[ref.media]}`; -} - -function formatBlobBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const units = ["KiB", "MiB", "GiB"]; - let value = bytes / 1024; - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit++; - } - return `${Number(value.toFixed(value < 10 ? 1 : 0))} ${units[unit]}`; -} - -/** - * Renders a blob a projection target cannot, or might not be able to, read as pointer text the - * model can follow with a tool that resolves `blob://` URIs, for example - * `[image/png, 68 KiB: blob://9f86d0….png]`. Shared by prompt projection and tool result parts. - */ -export function renderBlobPointer(ref: BlobRef): string { - const name = ref.name ? `, ${ref.name}` : ""; - return `[${ref.media}, ${formatBlobBytes(ref.bytes)}${name}: ${blobUri(ref)}]`; -} diff --git a/packages/core-agent/agent/domain-types.test.ts b/packages/core-agent/agent/domain-types.test.ts deleted file mode 100644 index 14ef55de..00000000 --- a/packages/core-agent/agent/domain-types.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import type { ForkSnapshot, SessionRequest } from "./agent-conversation.ts"; -import type { TurnState } from "./agent-fsm.ts"; -import type { OperationState } from "./operation-actor.ts"; -import { context } from "./test-support.ts"; -import type { BatchState } from "./tool-batch.ts"; -import { ref, SessionIdSchema, type Ref } from "./types.ts"; - -// Typechecked by tsc, never executed: each directive must correspond to a compiler error. -function invalidStates() { - const turn = context(); - const idle: TurnState = { - status: "idle", - id: turn.id, - agent: turn.agent, - steps: turn.steps, - // @ts-expect-error idle cannot carry active work - child: ref("completion", "request"), - }; - // @ts-expect-error done requires an outcome - const done: TurnState = { status: "done", record: { agent: turn.agent, messages: [] } }; - const executing: TurnState = { - status: "executing_tools", - turn, - // @ts-expect-error executing tools requires a batch reference - child: ref("completion", "request"), - }; - // @ts-expect-error running batch must have a validated nonempty pending set - const batch: BatchState = { status: "running", calls: [], pending: [], results: [] }; - // @ts-expect-error success requires validated complete results - const success: BatchState = { status: "settled", outcome: { kind: "succeeded", results: [] } }; - // @ts-expect-error only running carries request identity - const ready: OperationState = { status: "ready", request: ref("completion", "request") }; - // @ts-expect-error successful operation must carry its result - const completed: OperationState = { status: "succeeded" }; - // @ts-expect-error actor references cannot cross operation kinds - const reference: Ref<"completion"> = ref("tool", "request"); - const sessionId = SessionIdSchema.parse(crypto.randomUUID()); - // @ts-expect-error compaction messages must pass the context constructor - const compact: SessionRequest = { kind: "compact", id: turn.id, sessionId, context: [] }; - const activeFork: ForkSnapshot["turn"] = { - // @ts-expect-error a published fork cannot carry an active child - status: "awaiting_model", - turn, - child: ref("completion", "request"), - }; - // @ts-expect-error a fork never inherits its parent's queued requests - const queuedFork: ForkSnapshot["pending"] = [{ kind: "fork", id: turn.id, sessionId }]; - void [ - idle, - done, - executing, - batch, - success, - ready, - completed, - reference, - compact, - activeFork, - queuedFork, - ]; -} -test("domain invariant type assertions are included in the TypeScript check", () => { - expect(typeof invalidStates).toBe("function"); -}); diff --git a/packages/core-agent/agent/operation-actor.test.ts b/packages/core-agent/agent/operation-actor.test.ts deleted file mode 100644 index 3751a7ec..00000000 --- a/packages/core-agent/agent/operation-actor.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { createOperationActor } from "./operation-actor.ts"; -import { deferred, until } from "./test-support.ts"; -import { ref, type Result } from "./types.ts"; - -test("input validation, execution and output validation are distinct actor states", async () => { - const input = deferred(), - operation = deferred(), - output = deferred(); - const outcomes: Result[] = []; - const actor = createOperationActor( - ref("completion", "test/request"), - { - input: "raw", - parseInput: () => input.promise, - run: () => operation.promise, - parseOutput: () => output.promise, - }, - (result) => outcomes.push(result), - ); - await actor.start(); - expect(actor.snapshot.status).toBe("validating_input"); - input.resolve("parsed"); - await until(() => actor.snapshot.status === "running"); - expect(actor.snapshot).toEqual({ status: "running", request: ref("completion", "test/request") }); - operation.resolve(42); - await until(() => actor.snapshot.status === "validating_output"); - output.resolve(42); - await until(() => actor.snapshot.status === "succeeded"); - expect(outcomes).toEqual([{ kind: "succeeded", value: 42 }]); - await actor.cancel(); - expect(outcomes).toHaveLength(1); -}); - -for (const stage of ["input", "run", "output"] as const) { - test(`${stage} failure becomes a terminal actor outcome`, async () => { - const results: Result[] = []; - const actor = createOperationActor( - ref("completion", "test/request"), - { - input: "input", - parseInput: (value) => { - if (stage === "input") throw new Error("invalid input"); - return z.string().parse(value); - }, - run: () => { - if (stage === "run") throw new Error("I/O failed"); - return "result"; - }, - parseOutput: (value) => { - if (stage === "output") throw new Error("invalid output"); - return z.string().parse(value); - }, - }, - (result) => results.push(result), - ); - await actor.start(); - await until(() => actor.snapshot.status === "failed"); - expect(results[0]?.kind).toBe("failed"); - expect(actor.snapshot).not.toHaveProperty("value"); - }); -} - -test("cancellation during asynchronous validation prevents execution", async () => { - const validation = deferred(); - let executions = 0; - const outcomes: Result[] = []; - const actor = createOperationActor( - ref("completion", "test/request"), - { - input: "", - parseInput: () => validation.promise, - run: () => { - executions++; - return "ok"; - }, - parseOutput: z.string().parse, - }, - (result) => outcomes.push(result), - ); - await actor.start(); - await actor.cancel(); - validation.resolve("valid"); - await Bun.sleep(1); - expect(executions).toBe(0); - expect(outcomes).toEqual([{ kind: "cancelled" }]); -}); - -test("cancels actual work once and ignores a late adapter result", async () => { - const response = deferred(); - let signal!: AbortSignal, - aborts = 0; - const outcomes: Result[] = []; - const actor = createOperationActor( - ref("completion", "test/request"), - { - input: "", - parseInput: z.string().parse, - run: (_, current) => { - signal = current; - signal.addEventListener("abort", () => aborts++); - return response.promise; - }, - parseOutput: z.string().parse, - }, - (result) => outcomes.push(result), - ); - await actor.start(); - await until(() => Boolean(signal)); - await actor.cancel(); - await actor.cancel(); - response.resolve("too late"); - await Bun.sleep(1); - expect(aborts).toBe(1); - expect(outcomes).toEqual([{ kind: "cancelled" }]); - expect(actor.snapshot).toEqual({ status: "cancelled" }); -}); diff --git a/packages/core-agent/agent/operation-actor.ts b/packages/core-agent/agent/operation-actor.ts deleted file mode 100644 index 07a8f261..00000000 --- a/packages/core-agent/agent/operation-actor.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Actor, defineMachine, type Decision } from "../fsm/fsm.ts"; -import { failure, type ChildRef, type Failure, type Result } from "./types.ts"; - -/** - * State of one child operation of a turn (not a child session), such as a prompt projection, LLM - * call, permission request, handoff or single tool call. It runs ready → validating_input → running → - * validating_output → succeeded; `failed` and `cancelled` are reachable from any non-terminal state. - */ -export type OperationState = - | Readonly<{ status: "ready" }> - | Readonly<{ status: "validating_input" }> - | Readonly<{ status: "running"; request: ChildRef }> - | Readonly<{ status: "validating_output" }> - | Readonly<{ status: "succeeded"; value: T }> - /** Failed while validating or running, or cancelled with a `timeout` reason. */ - | Readonly<{ status: "failed"; error: Failure }> - | Readonly<{ status: "cancelled"; reason?: Failure }>; - -type Event = - | { type: "start" } - | { type: "cancel"; reason?: Failure } - | { type: "input_valid"; value: I } - | { type: "returned"; value: unknown } - | { type: "output_valid"; value: O } - | { type: "failed"; error: Failure }; - -type Command = - | { type: "observe" } - | { type: "validate_input" } - | { type: "run"; input: I } - | { type: "validate_output"; value: unknown } - | { type: "cancel"; reason?: Failure } - | { type: "notify"; result: Result }; - -/** The work an operation actor runs: parse the untrusted input, run, then parse the untrusted output. */ -export type Operation = { - /** Untrusted input, parsed by `parseInput`. */ - input: unknown; - /** Merged into every failure the operation reports. */ - failureContext?: Partial; - /** Deadline in ms, enforced by the host (not the actor): on expiry the operation fails with `timeout`. */ - timeoutMs?: number; - /** Rejections fail the operation as `invalid_input`. */ - parseInput: (input: unknown) => I | Promise; - /** `signal` aborts on cancel. Rejections fail the operation as `execution`. */ - run: (input: I, signal: AbortSignal) => unknown | Promise; - /** Rejections fail the operation as `invalid_output`. */ - parseOutput: (output: unknown) => O | Promise; -}; - -/** - * Creates the actor that runs one child operation of a turn. Nothing runs until `start()`. - * Controllers and arbitrary values stay in the adapter; only validated outputs enter snapshots. - * @param settled Called exactly once with the result when the operation succeeds, fails or is cancelled. - * A cancel whose reason is classified `timeout` settles as `failed`, not `cancelled`. - * @param observe Called with each new state for display; its errors are ignored. - * @returns `snapshot`, `start()` and `cancel(reason?)`. Their promises resolve when the event is - * decided, not when the operation settles; `settled` reports that. - */ -export function createOperationActor( - request: ChildRef, - operation: Operation, - settled: (result: Result) => void, - observe?: (state: OperationState) => unknown, -) { - type State = OperationState; - type Cmd = Command; - const cancel = ( - _: State, - event: Extract, { type: "cancel" }>, - ): Decision => - event.reason?.classification === "timeout" - ? { - state: { status: "failed", error: event.reason }, - commands: [ - { type: "cancel", reason: event.reason }, - { type: "notify", result: { kind: "failed", error: event.reason } }, - ], - } - : { - state: { status: "cancelled", ...(event.reason ? { reason: event.reason } : {}) }, - commands: [ - { type: "cancel", reason: event.reason }, - { type: "notify", result: { kind: "cancelled" } }, - ], - }; - const fail = ( - _: State, - event: Extract, { type: "failed" }>, - ): Decision => ({ - state: { status: "failed", error: event.error }, - commands: [ - { type: "cancel" }, - { type: "notify", result: { kind: "failed", error: event.error } }, - ], - }); - const decide = defineMachine, Cmd>({ - ready: { - start: () => ({ - state: { status: "validating_input" }, - commands: [{ type: "validate_input" }], - }), - cancel, - failed: fail, - }, - validating_input: { - input_valid: (_, event) => ({ - state: { status: "running", request }, - commands: [{ type: "run", input: event.value }], - }), - cancel, - failed: fail, - }, - running: { - returned: (_, event) => ({ - state: { status: "validating_output" }, - commands: [{ type: "validate_output", value: event.value }], - }), - cancel, - failed: fail, - }, - validating_output: { - output_valid: (_, event) => ({ - state: { status: "succeeded", value: event.value }, - commands: [{ type: "notify", result: { kind: "succeeded", value: event.value } }], - }), - cancel, - failed: fail, - }, - succeeded: {}, - failed: {}, - cancelled: {}, - }); - const controller = new AbortController(); - let actor: Actor, Cmd>; - const execute = (command: Cmd): undefined => { - const launch = (work: () => T | Promise, done: (value: T) => Event) => { - void Promise.resolve() - .then(() => { - controller.signal.throwIfAborted(); - return work(); - }) - .then((value) => actor.send(done(value))) - .catch((error) => - actor.send({ - type: "failed", - error: failure(error, { - classification: - command.type === "validate_input" - ? "invalid_input" - : command.type === "validate_output" - ? "invalid_output" - : "execution", - ...operation.failureContext, - operation: { - id: request.id, - kind: request.kind, - ...operation.failureContext?.operation, - }, - phase: command.type, - }), - }), - ); - }; - switch (command.type) { - case "observe": - // Display only: observer failures must never become operation failures. - try { - void Promise.resolve(observe?.(actor.snapshot)).catch(() => {}); - } catch {} - break; - case "validate_input": - launch( - () => operation.parseInput(operation.input), - (value) => ({ type: "input_valid", value }), - ); - break; - case "run": - launch( - () => operation.run(command.input, controller.signal), - (value) => ({ type: "returned", value }), - ); - break; - case "validate_output": - launch( - () => operation.parseOutput(command.value), - (value) => ({ type: "output_valid", value }), - ); - break; - case "cancel": - controller.abort(command.reason); - break; - case "notify": - settled(command.result); - break; - } - return undefined; - }; - actor = new Actor, Cmd>( - { status: "ready" }, - (state, event) => { - const decision = decide(state, event); - return observe && decision.state !== state - ? { ...decision, commands: [{ type: "observe" }, ...decision.commands] } - : decision; - }, - execute, - (_, error) => ({ type: "failed", error: failure(error) }), - ); - return { - get snapshot() { - return actor.snapshot; - }, - start: () => actor.send({ type: "start" }), - cancel: (reason?: Failure) => - actor.send({ type: "cancel", reason: reason ? failure(reason) : undefined }), - }; -} diff --git a/packages/core-agent/agent/permissions.ts b/packages/core-agent/agent/permissions.ts deleted file mode 100644 index ec0dfdc0..00000000 --- a/packages/core-agent/agent/permissions.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { z } from "zod"; - -import { FailureSchema, ToolCallIdSchema, type ToolCalls } from "./types.ts"; - -/** - * Result of a permission request for one tool batch: one decision per tool call, in call order. - * A `cancelled` decision stops the list early (nothing after it is asked); every other outcome - * covers every call. See {@link validatePermissionDecisions}. - * - * - `allow_once`: the call may run. `approval` is set when a session-wide grant allowed it: - * `source: "user"` when the user just chose "allow for this session", `"remembered"` when an - * earlier grant for the same tool name was reused. `grantId` names that grant. - * - `reject_once`: the user refused this call. That call does not run; its tool operation fails - * with classification `permission_refused`, which becomes a "permission refused" tool result - * for the model. Other calls in the same batch are unaffected and the turn continues. - * - `cancelled`: the user dismissed the request. No call in the batch runs, decisions for calls - * not yet asked about are omitted, and the turn ends `aborted`. - * - `invalid_input`: the call's arguments failed validation before approval was asked (only under - * the `return-error-and-continue` tool-failure setting). The call does not run; its tool - * operation fails with `error`, which the model receives as the call's result. - */ -export const PermissionDecisionsSchema = z - .array( - z.union([ - z - .strictObject({ - callId: ToolCallIdSchema, - decision: z.enum(["allow_once", "reject_once", "cancelled"]), - approval: z - .object({ - scope: z.literal("live-session-tool"), - source: z.enum(["user", "remembered"]), - grantId: z.string().min(1), - }) - .readonly() - .optional(), - }) - .readonly(), - z - .strictObject({ - callId: ToolCallIdSchema, - decision: z.literal("invalid_input"), - error: FailureSchema, - }) - .readonly(), - ]), - ) - .min(1) - .readonly(); -/** Per-call permission decisions for one tool batch. See {@link PermissionDecisionsSchema}. */ -export type PermissionDecisions = z.infer; - -/** - * Checks that permission decisions fit the batch's tool calls and returns them unchanged. - * - * Entry `i` must name `calls[i]`. A `cancelled` entry, if present, must be the last entry (no - * call after it was asked about). Any other outcome must cover every call: the list's length must - * equal `calls.length`. - * - * @throws Error when the decisions do not match the calls. - */ -export function validatePermissionDecisions(calls: ToolCalls, decisions: PermissionDecisions) { - const cancelledIndex = decisions.findIndex((entry) => entry.decision === "cancelled"); - const stoppedEarly = cancelledIndex !== -1; - if ( - (stoppedEarly && cancelledIndex !== decisions.length - 1) || - (!stoppedEarly && decisions.length !== calls.length) || - decisions.some((entry, index) => entry.callId !== calls[index]?.id) - ) - throw new Error("Permission decisions do not match admitted tool calls"); - return decisions; -} diff --git a/packages/core-agent/agent/prompt.ts b/packages/core-agent/agent/prompt.ts deleted file mode 100644 index f510b3d7..00000000 --- a/packages/core-agent/agent/prompt.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { ChatMessage } from "./agent.ts"; -import { - partsText, - renderBlobPointer, - type BlobId, - type ContentPart, - type MediaKind, - type MediaSupport, -} from "./content.ts"; -import { - MessagesSchema, - ToolCallsSchema, - type AgentMessage, - type TurnData, - type TurnRecord, -} from "./types.ts"; - -/** Everything prompt projection may draw on to build the next step's request. */ -export type PromptInput = Readonly<{ - /** Session context placed before the finished turns, e.g. a compaction's replacement context. */ - context?: readonly AgentMessage[]; - /** Finished turns of this session, oldest first. */ - log: readonly TurnRecord[]; - /** The turn in progress. A `handoff` view marks the successor's steps after a handoff. */ - turn: TurnData; - /** - * On a handoff step, the handoff packet (built by the policy's handoff resolver from the turn's - * messages up to `turn.view.at`) followed by the messages added since. Computed when the step's - * prompt is projected and never stored; absent on an ordinary step. - */ - handoff?: readonly AgentMessage[]; - /** - * The provider and model the step will call, with that model's capabilities. Omitted only for - * projections with no bound provider (for example the in-memory `/agent` runtime): every blob - * part is kept as-is, since no target capability is known. When set, a blob part the target - * cannot, or might not, read is rendered as pointer text (see {@link renderBlobPointer}) instead - * of being sent. - */ - target?: Readonly<{ - provider: string; - model: string; - /** Three-valued per media kind. A plain record: crosses `structuredClone` in the host. */ - media: Readonly>; - }>; - /** - * The agent that will run the step. {@link projectConversationPrompt} uses only `systemPrompt`; - * the other fields are for custom projections. - */ - agent: Readonly<{ - successors?: readonly string[]; - model: string; - systemPrompt?: string; - tools: readonly string[]; - }>; -}>; - -/** - * Validates that `messages` holds only complete, correlated tool exchanges (every call answered, - * once, by the call that follows it), repairing a trailing interrupted exchange when `interrupted`. - * Exported so a target-aware or handoff projection can validate a slice of history the same way. - * @throws Error naming `source` when a result is orphaned, duplicated, or a non-trailing call is - * left unanswered. - */ -export function completedExchanges( - messages: readonly AgentMessage[], - source: string, - interrupted = false, -): AgentMessage[] { - const output: AgentMessage[] = []; - for (let index = 0; index < messages.length; index++) { - const message = messages[index]!; - if (message.role === "tool") - throw new Error(`Invalid tool history in ${source}: orphan result ${message.callId}`); - if (message.role !== "assistant" || !message.calls) { - output.push(message); - continue; - } - const results: Extract[] = []; - while (messages[index + 1]?.role === "tool") { - const next = messages[++index]!; - if (next.role === "tool") results.push(next); - } - const ids = new Set(message.calls.map((call) => call.id)); - const resultIds = new Set(results.map((result) => result.callId)); - if (resultIds.size !== results.length || results.some((result) => !ids.has(result.callId))) { - throw new Error(`Invalid tool history in ${source}: duplicate or unmatched tool IDs`); - } - const missing = message.calls.filter((call) => !resultIds.has(call.id)); - if (missing.length && !(interrupted && index === messages.length - 1)) { - throw new Error( - `Invalid tool history in ${source}: missing results for ${missing.map((call) => call.id).join(", ")}`, - ); - } - const calls = message.calls.filter((call) => resultIds.has(call.id)); - output.push( - calls.length - ? { ...message, calls: ToolCallsSchema.parse(calls) } - : { - role: "assistant", - text: message.text, - ...(message.parts ? { parts: message.parts } : {}), - ...(message.owner ? { owner: message.owner } : {}), - }, - ); - output.push(...results); - } - return output; -} - -/** Replacement context must contain complete, correlated exchanges. */ -const SessionContextSchema = MessagesSchema.transform((messages) => { - completedExchanges(messages, "session context"); - return messages; -}).brand<"SessionContext">(); -/** - * Session context that was checked to hold only complete, correlated tool exchanges. - * Build it with {@link parseSessionContext}. - */ -export type SessionContext = ReturnType; -/** - * Validates messages as session context, for example a compaction's replacement context. - * @throws When a message is invalid, or a tool exchange has an orphan, duplicate, unmatched or - * missing result. - */ -export const parseSessionContext = (raw: unknown): SessionContext => - SessionContextSchema.parse(raw); - -/** - * Renders domain messages ({@link AgentMessage}) as provider-agnostic chat messages - * ({@link ChatMessage}): tool results become `tool` messages keyed by call ID, an assistant - * message with calls carries `tool_calls`, and any `parts` or completion `owner` pass through. - * Exported so a handoff or target-aware projection can render a slice of history the same way. - */ -export function agentMessagesToChat(messages: readonly AgentMessage[]): ChatMessage[] { - return messages.map((message) => { - if (message.role === "tool") - return { - role: "tool", - content: message.text, - tool_call_id: message.callId, - ...(message.parts ? { parts: message.parts } : {}), - }; - if (message.role === "assistant" && message.calls) - return { - role: "assistant", - content: message.text, - ...(message.parts ? { parts: message.parts } : {}), - ...(message.owner ? { owner: message.owner } : {}), - tool_calls: message.calls.map((call) => ({ - id: call.id, - type: "function", - function: { name: call.name, arguments: JSON.stringify(call.args) }, - })), - }; - return { - role: message.role, - content: message.text, - ...(message.parts ? { parts: message.parts } : {}), - ...(message.role === "assistant" && message.owner ? { owner: message.owner } : {}), - }; - }); -} - -/** - * One blob part a target-aware rewrite replaced with pointer text; see - * {@link projectMediaPointers}. Callers with correlation context (session, turn, operation ids) - * turn these into `prompt.media.pointer` diagnostic events; this module stays pure and never logs. - */ -export type MediaPointerEvent = Readonly<{ - media: MediaKind; - bytes: number; - name?: string; - blobId: BlobId; - support: MediaSupport; -}>; - -/** - * A step's projected prompt: the chat messages to send (validated by the host before dispatch) and - * the blob parts the target-aware rewrite replaced with pointers, which the step reports. - */ -export type ProjectedPrompt = Readonly<{ - messages: unknown; - pointers: readonly MediaPointerEvent[]; -}>; - -/** - * Rewrites every message's blob parts for the target's capabilities (D3): a part the target - * supports is kept; a part it does not support, or might not, becomes a text pointer (see - * {@link renderBlobPointer}), on a line of its own so it never glues onto neighbouring text. - * `content` is recomputed to match. Applies uniformly to whatever a projection (a built-in pack, a - * custom pack, or a handoff packet later re-projected) produced, covering every role that carries - * `parts`, tool results included. Pure: never logs: the caller (`projectPolicy`) reports the - * returned `pointers` with its own correlation ids. - */ -export function projectMediaPointers( - messages: readonly ChatMessage[], - target: NonNullable, -): { messages: ChatMessage[]; pointers: MediaPointerEvent[] } { - const pointers: MediaPointerEvent[] = []; - const out = messages.map((message) => { - if (!("parts" in message) || !message.parts) return message; - let changed = false; - const parts: ContentPart[] = message.parts.map((part) => { - if (part.type !== "blob") return part; - const support = target.media[part.ref.media]; - if (support === "supported") return part; - changed = true; - pointers.push({ - media: part.ref.media, - bytes: part.ref.bytes, - ...(part.ref.name ? { name: part.ref.name } : {}), - blobId: part.ref.id, - support, - }); - return { type: "text", text: `\n${renderBlobPointer(part.ref)}` }; - }); - if (!changed) return message; - return { ...message, content: partsText(parts), parts }; - }); - return { messages: out, pointers }; -} - -/** - * The default prompt projection: builds the chat messages for the next step. The agent's - * `systemPrompt`, when set, comes first as a `system` message. On an ordinary step: session - * context, every finished turn and the current turn's messages. On a handoff step: only - * `input.handoff`. Stored history is not changed. - * - * Tool exchanges must be complete. The one exception is the last exchange of a finished turn that - * did not complete: calls without results are dropped from the request. - * - * @throws When any source (including one the step does not send) has an orphan, duplicate or - * unmatched tool result, or a tool call without a result outside that exception. - */ -export function projectConversationPrompt({ - context = [], - log, - turn, - agent, - handoff, -}: PromptInput): ChatMessage[] { - const base = completedExchanges(context, "session context"); - const history = log.flatMap((record, index) => - completedExchanges( - record.messages, - `logged turn ${index + 1}`, - record.outcome.kind !== "completed", - ), - ); - const current = completedExchanges(turn.messages, "current turn"); - return [ - ...(agent.systemPrompt ? [{ role: "system" as const, content: agent.systemPrompt }] : []), - ...agentMessagesToChat( - handoff ? completedExchanges(handoff, "handoff packet") : [...base, ...history, ...current], - ), - ]; -} diff --git a/packages/core-agent/agent/session.test.ts b/packages/core-agent/agent/session.test.ts deleted file mode 100644 index c3547b22..00000000 --- a/packages/core-agent/agent/session.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { createAgentRuntime, defineTool, type RuntimeOptions } from "./agent-runtime.ts"; -import type { ChatCompletionRequest } from "./agent.ts"; -import { deferred, until } from "./test-support.ts"; - -function harness(overrides: Partial = {}) { - const requests: Array<{ request: ChatCompletionRequest } & ReturnType>> = - []; - const runtime = createAgentRuntime({ - agent: "writer", - agents: new Map([ - ["writer", { model: "write", tools: ["lookup"] }], - ["reviewer", { model: "review" }], - ]), - tools: new Map([["lookup", defineTool({ input: z.object({}), run: () => "found" })]]), - baseUrl: "http://localhost/v1", - steps: 4, - complete: (request) => { - const response = deferred(); - requests.push({ request, ...response }); - return response.promise; - }, - ...overrides, - }); - return { runtime, requests }; -} - -const answer = (text: string) => ({ kind: "answer", text }); - -const toolCalls = (...ids: string[]) => ({ - kind: "tools", - text: "searching", - calls: ids.map((id) => ({ id, name: "lookup", args: {} })), -}); - -async function finish(h: ReturnType, text = "first") { - await h.runtime.fire({ type: "user", text }); - await until(() => h.requests.length > 0); - h.requests.at(-1)!.resolve(answer("answer")); - await until(() => h.runtime.snapshot.conversation.turn.status === "idle"); -} - -test("fork clones a completed session under fresh identities and continues independently", async () => { - const h = harness(); - await finish(h); - const before = h.runtime.snapshot.conversation; - const fork = await h.runtime.fork(); - const cloned = fork.snapshot.conversation; - expect(cloned.sessionId).not.toBe(before.sessionId); - expect(cloned.turnId).not.toBe(before.turnId); - expect(cloned.log).toEqual(before.log); - expect(cloned.context).toEqual(before.context); - expect(cloned.origin).toEqual({ - kind: "fork", - parent: before.sessionId, - sequence: before.sequence, - }); - expect(cloned.pending).toEqual([]); - expect(fork.snapshot.children).toEqual([]); - expect(h.runtime.snapshot.conversation.log).toBe(before.log); - await h.runtime.fire({ type: "user", text: "original branch" }); - await fork.fire({ type: "user", text: "fork branch" }); - await until(() => h.requests.length === 3); - expect(h.requests[1]!.request.signal).not.toBe(h.requests[2]!.request.signal); - expect(h.requests[2]!.request.messages.map((message) => message.content)).toEqual([ - "first", - "answer", - "fork branch", - ]); - await h.runtime.fire({ type: "abort" }); - expect(h.requests[1]!.request.signal?.aborted).toBe(true); - expect(h.requests[2]!.request.signal?.aborted).toBe(false); - h.requests[1]!.resolve(answer("late original")); - h.requests[2]!.resolve(answer("fork answer")); - await until(() => fork.snapshot.conversation.log.length === 2); - expect(fork.snapshot.conversation.log[1]?.outcome.kind).toBe("completed"); - expect(h.runtime.snapshot.conversation.log[1]?.outcome.kind).toBe("aborted"); - expect(before.log).toHaveLength(1); -}); - -test("fork waits for Done, includes the settled answer, and preserves barge-in", async () => { - const h = harness(); - await h.runtime.fire({ type: "user", text: "first" }); - await until(() => h.requests.length === 1); - let published = false; - const pending = h.runtime.fork().then((fork) => { - published = true; - return fork; - }); - await until(() => h.runtime.snapshot.conversation.pending.length === 1); - expect(published).toBe(false); - await h.runtime.fire({ type: "user", text: "replacement" }); - await until(() => h.requests.length === 2); - expect(h.requests[0]!.request.signal?.aborted).toBe(true); - h.requests[0]!.resolve(answer("stale")); - expect(published).toBe(false); - h.requests[1]!.resolve(answer("current")); - const fork = await pending; - expect(fork.snapshot.conversation.log[0]?.messages.map((message) => message.text)).toEqual([ - "first", - "replacement", - "current", - ]); - expect(fork.snapshot.conversation.turn.status).toBe("idle"); - expect(fork.snapshot.children).toEqual([]); -}); - -test("fork and compaction wait through the complete tool/model loop without replaying tools", async () => { - const tools: Array>> = []; - const h = harness({ - tools: new Map([ - [ - "lookup", - defineTool({ - input: z.object({}), - run: () => { - const call = deferred(); - tools.push(call); - return call.promise; - }, - }), - ], - ]), - }); - await h.runtime.fire({ type: "user", text: "lookup" }); - await until(() => h.requests.length === 1); - h.requests[0]!.resolve(toolCalls("a", "b")); - await until(() => tools.length === 2); - const sourceId = h.runtime.snapshot.conversation.sessionId; - let forks = 0; - const normal = h.runtime.fork().then((value) => { - forks++; - return value; - }); - const compacted = h.runtime - .compact([{ role: "user", text: "Condensed context" }]) - .then((value) => { - forks++; - return value; - }); - await until(() => h.runtime.snapshot.conversation.pending.length === 2); - tools[0]!.resolve("one"); - await until( - () => h.runtime.snapshot.children.filter((child) => child.ref.kind === "tool").length === 1, - ); - expect(forks).toBe(0); - tools[1]!.resolve("two"); - await until(() => h.requests.length === 2); - expect(forks).toBe(0); - h.requests[1]!.resolve(answer("finished")); - const [fork, compact] = await Promise.all([normal, compacted]); - expect(fork.snapshot.conversation.log[0]?.messages.map((message) => message.text)).toEqual([ - "lookup", - "searching", - "one", - "two", - "finished", - ]); - expect(compact.snapshot.conversation.log).toEqual([]); - expect(compact.snapshot.conversation.context).toMatchObject([ - { role: "user", text: "Condensed context" }, - ]); - expect(compact.snapshot.conversation.origin).toMatchObject({ - kind: "compaction", - parent: sourceId, - }); - expect(compact.snapshot.conversation.sessionId).not.toBe(fork.snapshot.conversation.sessionId); - expect(h.runtime.snapshot.conversation.log).toHaveLength(1); - expect(tools).toHaveLength(2); - await compact.fire({ type: "user", text: "continue" }); - await until(() => h.requests.length === 3); - expect(h.requests[2]!.request.messages.map((message) => message.content)).toEqual([ - "Condensed context", - "continue", - ]); - h.requests[2]!.resolve(answer("done")); - await until(() => compact.snapshot.conversation.log.length === 1); -}); - -test("compaction replaces earlier context and history without mutating any ancestor", async () => { - const h = harness(); - await finish(h); - const first = await h.runtime.compact([{ role: "user", text: "old summary" }]); - const replacement = [{ role: "user", text: "new summary" }]; - const compacting = first.compact(replacement); - replacement[0]!.text = "mutated by caller"; - const second = await compacting; - const clone = await second.fork(); - expect(second.snapshot.conversation.context).toMatchObject([{ text: "new summary" }]); - expect(Object.isFrozen(second.snapshot.conversation.context[0])).toBe(true); - expect(first.snapshot.conversation.context).toMatchObject([{ text: "old summary" }]); - expect(h.runtime.snapshot.conversation.log).toHaveLength(1); - expect(clone.snapshot.conversation.context).toEqual(second.snapshot.conversation.context); - await clone.fire({ type: "user", text: "next" }); - await until(() => h.requests.length === 2); - expect(h.requests[1]!.request.messages.map((message) => message.content)).toEqual([ - "new summary", - "next", - ]); - h.requests[1]!.resolve(answer("done")); - await until(() => clone.snapshot.conversation.log.length === 1); -}); - -test("invalid compaction is rejected atomically without changing source state or starting I/O", async () => { - const h = harness(); - await finish(h); - const before = h.runtime.snapshot.conversation; - for (const context of [ - null, - [{ role: "user", text: 42 }], - [{ role: "tool", callId: "a", text: "orphan" }], - [{ role: "assistant", text: "incomplete", calls: [{ id: "a", name: "lookup", args: {} }] }], - ]) { - await expect(h.runtime.compact(context)).rejects.toThrow(); - expect(h.runtime.snapshot.conversation).toBe(before); - expect(h.runtime.snapshot.children).toHaveLength(0); - expect(h.requests).toHaveLength(1); - } - const fork = await h.runtime.fork(); - expect(fork.snapshot.conversation.log).toEqual(before.log); -}); - -test("aborting a tool batch releases pending forks with partial history and isolates late outcomes", async () => { - const calls: Array>> = []; - const h = harness({ - tools: new Map([ - [ - "lookup", - defineTool({ - input: z.object({}), - run: () => { - const result = deferred(); - calls.push(result); - return result.promise; - }, - }), - ], - ]), - }); - await h.runtime.fire({ type: "user", text: "lookup" }); - await until(() => h.requests.length === 1); - h.requests[0]!.resolve(toolCalls("a", "b")); - await until(() => calls.length === 2); - calls[0]!.resolve("found"); - await until( - () => h.runtime.snapshot.children.filter((child) => child.ref.kind === "tool").length === 1, - ); - const pending = h.runtime.fork(); - await h.runtime.fire({ type: "abort" }); - const fork = await pending; - expect(fork.snapshot.conversation.log[0]?.outcome.kind).toBe("aborted"); - expect(fork.snapshot.conversation.log[0]?.messages.at(-1)?.text).toBe("found"); - calls[1]!.resolve("late"); - await fork.fire({ type: "user", text: "continue" }); - await until(() => h.requests.length === 2); - expect(h.requests[1]!.request.messages.some((message) => message.content === "late")).toBe(false); - h.requests[1]!.resolve(answer("done")); - await until(() => fork.snapshot.conversation.log.length === 2); - expect(h.runtime.snapshot.conversation.log).toHaveLength(1); -}); - -test("handoff and exhausted turns publish forks with the final agent and fresh allowance", async () => { - const h = harness({ steps: 1 }); - await h.runtime.fire({ type: "user", text: "handoff" }); - await until(() => h.requests.length === 1); - const pending = h.runtime.fork(); - h.requests[0]!.resolve({ kind: "handoff", text: "review", agent: "reviewer" }); - const fork = await pending; - expect(fork.snapshot.conversation.log[0]?.outcome.kind).toBe("exhausted"); - expect(fork.snapshot.conversation.turn).toMatchObject({ - status: "idle", - agent: "reviewer", - steps: 1, - }); - await fork.fire({ type: "user", text: "continue" }); - await until(() => h.requests.length === 2); - expect(h.requests[1]!.request.model).toBe("review"); - h.requests[1]!.resolve(answer("done")); - await until(() => fork.snapshot.conversation.log.length === 2); -}); - -test("fork captures its mailbox boundary even if source input is already queued next", async () => { - const h = harness(); - await finish(h); - const pending = h.runtime.fork(); - const next = h.runtime.fire({ type: "user", text: "source only" }); - const fork = await pending; - await next; - expect(fork.snapshot.conversation.turn.status).toBe("idle"); - expect(fork.snapshot.conversation.log).toHaveLength(1); - expect(fork.snapshot.children).toHaveLength(0); - expect(h.runtime.snapshot.conversation.turn.status).not.toBe("idle"); - await until(() => h.requests.length === 2); - h.requests[1]!.resolve(answer("source answer")); - await until(() => h.runtime.snapshot.conversation.log.length === 2); - expect(fork.snapshot.conversation.log).toHaveLength(1); -}); - -test("failed turn outcomes release pending forks and later source work stays independent", async () => { - const h = harness(); - await h.runtime.fire({ type: "user", text: "work" }); - await until(() => h.requests.length === 1); - const pending = h.runtime.fork(); - h.requests[0]!.reject(new Error("offline")); - const fork = await pending; - expect(fork.snapshot.conversation.log[0]?.outcome).toMatchObject({ - kind: "failed", - error: { message: "offline" }, - }); - expect(fork.snapshot.children).toEqual([]); - await fork.fire({ type: "user", text: "retry here" }); - await until(() => h.requests.length === 2); - h.requests[1]!.resolve(answer("recovered")); - await until(() => fork.snapshot.conversation.log.length === 2); - expect(h.runtime.snapshot.conversation.log).toHaveLength(1); -}); - -test("forks inherit validated configuration rather than mutated caller registries", async () => { - const agents = new Map([["writer", { model: "original", tools: ["lookup"] }]]); - const tools = new Map([ - ["lookup", defineTool({ input: z.object({}), run: () => "original result" })], - ]); - const h = harness({ agents, tools }); - agents.get("writer")!.model = "changed"; - agents.clear(); - tools.clear(); - const fork = await h.runtime.fork(); - await fork.fire({ type: "user", text: "lookup" }); - await until(() => h.requests.length === 1); - expect(h.requests[0]!.request.model).toBe("original"); - h.requests[0]!.resolve(toolCalls("a")); - await until(() => h.requests.length === 2); - expect(h.requests[1]!.request.messages.at(-1)?.content).toBe("original result"); - h.requests[1]!.resolve(answer("done")); - await until(() => fork.snapshot.conversation.log.length === 1); -}); diff --git a/packages/core-agent/agent/test-support.ts b/packages/core-agent/agent/test-support.ts deleted file mode 100644 index 82ae06a4..00000000 --- a/packages/core-agent/agent/test-support.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ActorIdSchema, AgentIdSchema, StepsSchema, type TurnData } from "./types.ts"; - -export const context = (): TurnData => ({ - id: ActorIdSchema.parse("turn/1"), - generation: 0, - agent: AgentIdSchema.parse("writer"), - steps: StepsSchema.parse(6), - messages: [], - view: { kind: "history" }, -}); -export function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} -export async function until(predicate: () => boolean) { - for (let attempt = 0; attempt < 200; attempt++) { - if (predicate()) return; - await Bun.sleep(1); - } - throw new Error("Actor did not reach the expected state"); -} diff --git a/packages/core-agent/agent/tool-batch.test.ts b/packages/core-agent/agent/tool-batch.test.ts deleted file mode 100644 index e82422de..00000000 --- a/packages/core-agent/agent/tool-batch.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { completeResults, toolBatchMachine } from "./tool-batch.ts"; -import { ref, ToolCallIdSchema, ToolCallsSchema } from "./types.ts"; - -const calls = ToolCallsSchema.parse([ - { id: "a", name: "search", args: {} }, - { id: "b", name: "search", args: {} }, -]); -const decide = toolBatchMachine(ref("batch", "turn/1/batch")); -const result = (id: string) => ({ - type: "tool_settled" as const, - callId: ToolCallIdSchema.parse(id), - result: { kind: "succeeded" as const, value: { text: id } }, -}); -test("only outstanding calls advance the batch; the last result produces a complete outcome", () => { - const started = decide({ status: "ready", calls }, { type: "start" }); - expect(started.commands.map((command) => command.type)).toEqual(["spawn_tool", "spawn_tool"]); - const first = decide(started.state, result("b")); - expect(first.state).toMatchObject({ - status: "running", - pending: [{ id: "a" }], - results: [{ callId: "b", text: "b" }], - }); - expect(first.commands).toEqual([]); - for (const id of ["b", "unknown"]) - expect(decide(first.state, result(id)).state).toBe(first.state); - const last = decide(first.state, result("a")); - expect(last.state).toMatchObject({ - status: "settled", - outcome: { - kind: "succeeded", - results: [ - { callId: "b", text: "b" }, - { callId: "a", text: "a" }, - ], - }, - }); - expect(last.commands.map((command) => command.type)).toEqual(["notify"]); - expect(decide(last.state, result("a")).state).toBe(last.state); -}); -test("cancellation preserves settled results and cancels only outstanding children", () => { - const running = decide({ status: "ready", calls }, { type: "start" }).state; - const partial = decide(running, result("b")).state; - const cancelled = decide(partial, { type: "cancel" }); - expect(cancelled.state).toMatchObject({ - status: "settled", - outcome: { kind: "cancelled", results: [{ callId: "b", text: "b" }] }, - }); - expect(cancelled.commands).toEqual([ - { type: "cancel_tool", child: ref("tool", "turn/1/batch/a") }, - { - type: "notify", - outcome: { kind: "cancelled", results: [{ callId: ToolCallIdSchema.parse("b"), text: "b" }] }, - }, - ]); -}); -test("complete-result construction rejects missing, duplicate and unrelated IDs", () => { - for (const ids of [[], ["a"], ["a", "a"], ["a", "unknown"], ["a", "b", "c"]]) { - expect(() => - completeResults( - calls, - ids.map((id) => ({ callId: ToolCallIdSchema.parse(id), text: "result" })), - ), - ).toThrow(); - } -}); diff --git a/packages/core-agent/agent/tool-batch.ts b/packages/core-agent/agent/tool-batch.ts deleted file mode 100644 index 6b48c2b2..00000000 --- a/packages/core-agent/agent/tool-batch.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { z } from "zod"; - -import { defineMachine, stay, type Decision } from "../fsm/fsm.ts"; -import { ContentPartsSchema, partsText } from "./content.ts"; -import { - ref, - ToolCallIdSchema, - ToolCallsSchema, - type Failure, - type Ref, - type Result, - type ToolCalls, -} from "./types.ts"; - -const toolRunResultShape = { text: z.string(), parts: ContentPartsSchema.optional() }; - -/** - * What a tool call produced, before it is addressed to a call id: inline text, or text plus blob - * references (for example an image). When `parts` is present, `text` must equal the concatenation - * of its text parts; see {@link partsText}. - */ -export const ToolRunResultSchema = z - .strictObject(toolRunResultShape) - .refine( - (result) => !result.parts || result.text === partsText(result.parts), - "Tool result text must equal its text parts", - ) - .readonly(); -/** See {@link ToolRunResultSchema}. */ -export type ToolRunResult = z.infer; - -/** Result of one tool call, as sent back to the model in the next step. See {@link ToolRunResultSchema}. */ -export const ToolResultSchema = z - .strictObject({ callId: ToolCallIdSchema, ...toolRunResultShape }) - .refine( - (result) => !result.parts || result.text === partsText(result.parts), - "Tool result text must equal its text parts", - ) - .readonly(); -/** See {@link ToolResultSchema}. */ -export type ToolResult = z.infer; -const CompleteResultsSchema = z - .tuple([ToolResultSchema]) - .rest(ToolResultSchema) - .readonly() - .brand<"CompleteResults">(); -/** - * Checks that `results` answers every call in `calls` exactly once. - * @throws ZodError when a result is missing, duplicated or names an unknown call. - */ -export function completeResults(calls: ToolCalls, results: readonly ToolResult[]) { - return CompleteResultsSchema.refine( - (values) => - values.length === calls.length && - new Set(values.map((value) => value.callId)).size === calls.length && - values.every((value) => calls.some((call) => call.id === value.callId)), - "Results must match every tool call exactly once", - ).parse(results); -} -/** Results that cover every call of a tool batch exactly once; see {@link completeResults}. */ -export type CompleteResults = z.infer; -/** - * How a tool batch settled. `results` holds the results of the calls that succeeded before it settled. - */ -export type BatchOutcome = - /** Every call succeeded; the turn starts the next step. */ - | Readonly<{ kind: "succeeded"; results: CompleteResults }> - /** - * A call failed (after the host applied the tool-failure policy) or the batch failed; the other - * pending calls were cancelled with `error` and the turn ends failed. - */ - | Readonly<{ kind: "failed"; results: readonly ToolResult[]; error: Failure }> - /** The batch or a call was cancelled; the other pending calls were cancelled and the turn ends aborted. */ - | Readonly<{ kind: "cancelled"; results: readonly ToolResult[] }>; -/** State of the tool batch of one step: all calls the step proposed. Calls run concurrently. */ -export type BatchState = - | Readonly<{ status: "ready"; calls: ToolCalls }> - /** `pending` are calls not yet settled; `results` are those that succeeded, in settle order. */ - | Readonly<{ - status: "running"; - calls: ToolCalls; - pending: ToolCalls; - results: readonly ToolResult[]; - }> - | Readonly<{ status: "settled"; outcome: BatchOutcome }>; -/** Input to the tool batch machine. Events for calls that are not pending are ignored. */ -export type BatchEvent = - /** Spawn every call. */ - | { type: "start" } - /** Cancel all pending calls and settle `cancelled`. */ - | { type: "cancel" } - /** One call settled; a failed or cancelled call settles the whole batch. */ - | { type: "tool_settled"; callId: ToolResult["callId"]; result: Result } - | { type: "failed"; error: Failure }; -/** Effect the batch asks its host to perform; `notify` reports the {@link BatchOutcome} to the turn. */ -export type BatchCommand = - | { type: "spawn_tool"; child: Ref<"tool">; call: ToolCalls[number] } - | { type: "cancel_tool"; child: Ref<"tool">; reason?: Failure } - | { type: "notify"; outcome: BatchOutcome }; - -/** - * Builds the tool batch machine for batch `id`. Each call runs as a `tool` child with ref - * `/`. The batch settles once: when every call succeeded, or at the first failed or - * cancelled call. - */ -export function toolBatchMachine(id: Ref<"batch">) { - const toolRef = (callId: string) => ref("tool", `${id.id}/${callId}`); - const settle = ( - pending: ToolCalls, - outcome: BatchOutcome, - ): Decision => ({ - state: { status: "settled", outcome }, - commands: [ - ...pending.map((call) => ({ - type: "cancel_tool" as const, - child: toolRef(call.id), - ...(outcome.kind === "failed" ? { reason: outcome.error } : {}), - })), - { type: "notify", outcome }, - ], - }); - return defineMachine({ - ready: { - start: (state) => ({ - state: { status: "running", calls: state.calls, pending: state.calls, results: [] }, - commands: state.calls.map((call) => ({ - type: "spawn_tool", - child: toolRef(call.id), - call, - })), - }), - cancel: (state) => settle(state.calls, { kind: "cancelled", results: [] }), - failed: (state, event) => - settle(state.calls, { kind: "failed", error: event.error, results: [] }), - }, - running: { - tool_settled: (state, event) => { - if (!state.pending.some((call) => call.id === event.callId)) return stay(state); - if (event.result.kind === "failed") - return settle(state.pending, { - kind: "failed", - error: event.result.error, - results: state.results, - }); - if (event.result.kind === "cancelled") - return settle(state.pending, { kind: "cancelled", results: state.results }); - const results = [...state.results, { callId: event.callId, ...event.result.value }]; - const remaining = state.pending.filter((call) => call.id !== event.callId); - if (remaining.length) - return { - state: { - status: "running", - calls: state.calls, - pending: ToolCallsSchema.parse(remaining), - results, - }, - commands: [], - }; - const outcome: BatchOutcome = { - kind: "succeeded", - results: completeResults(state.calls, results), - }; - return { state: { status: "settled", outcome }, commands: [{ type: "notify", outcome }] }; - }, - cancel: (state) => settle(state.pending, { kind: "cancelled", results: state.results }), - failed: (state, event) => - settle(state.pending, { kind: "failed", error: event.error, results: state.results }), - }, - settled: {}, - }); -} diff --git a/packages/core-agent/agent/types.ts b/packages/core-agent/agent/types.ts deleted file mode 100644 index 3ab2d08a..00000000 --- a/packages/core-agent/agent/types.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { z } from "zod"; - -import { freeze } from "../fsm/fsm.ts"; -import { diagnosticError } from "../logging/index.ts"; -import { ContentPartsSchema, partsText } from "./content.ts"; - -export * from "./content.ts"; - -/** Name of a registered agent. Handoff targets and turn records name agents by this id. */ -export const AgentIdSchema = z.string().min(1).brand<"AgentId">(); - -/** Name of a registered tool, as the model uses it in a tool call. */ -export const ToolNameSchema = z.string().min(1).brand<"ToolName">(); - -/** - * Id of one tool call, as the model's step assigned it. A tool result message names its call by - * this id; it is unique within one step's calls. - */ -export const ToolCallIdSchema = z.string().min(1).brand<"ToolCallId">(); - -/** Identity of a session: a UUID. */ -export const SessionIdSchema = z.string().uuid().brand<"SessionId">(); - -/** Identity of a session: a UUID. */ -export type SessionId = z.infer; - -/** - * Identity of a turn or of an operation the runtime spawned. Turn ids have the form - * `/turn/`; child operations of a turn use `/`, and a tool - * call's operation uses `/`. - */ -export const ActorIdSchema = z.string().min(1).brand<"ActorId">(); - -/** - * The step that produced an assistant message: its turn id and the `generation` of that step's - * completion operation. Provider continuations (thinking signatures and similar payloads, not - * the next step) carry the same owner, so a later request can send each payload back with the - * assistant message it belongs to. - */ -export const CompletionOwnerSchema = z - .strictObject({ - turnId: ActorIdSchema, - generation: z.number().int().nonnegative(), - }) - .readonly(); - -/** - * A number of steps (LLM calls): a policy's per-turn allowance, or what a turn has left of it. - * One is used when a step starts; see docs/core-runtime.md. - */ -export const StepsSchema = z.number().int().nonnegative().brand<"Steps">(); - -/** - * A step allowance of at least one: the turn may still start a step. - * Parsing zero fails with "A model step requires remaining steps". - */ -export const PositiveStepsSchema = StepsSchema.refine( - (steps) => steps > 0, - "A model step requires remaining steps", -).brand<"PositiveSteps">(); - -/** Step allowance of at least one. See {@link PositiveStepsSchema}. */ -export type PositiveSteps = z.infer; - -/** Name of a registered agent. */ -export type AgentId = z.infer; - -/** Identity of a turn or of a spawned operation. See {@link ActorIdSchema}. */ -export type ActorId = z.infer; - -/** A number of steps (LLM calls): a per-turn allowance or what is left of it. */ -export type Steps = z.infer; - -/** Any JSON value. */ -export type Json = z.infer>; - -/** - * One tool invocation proposed by a step. `args` is the JSON the model sent; the tool validates - * it before the call runs (and before permission is asked, when permission is required). - */ -export const ToolCallSchema = z - .strictObject({ id: ToolCallIdSchema, name: ToolNameSchema, args: z.json() }) - .readonly(); - -/** The tool batch of one step: at least one call, with unique call ids, in the model's order. */ -export const ToolCallsSchema = z - .tuple([ToolCallSchema]) - .rest(ToolCallSchema) - .refine( - (calls) => new Set(calls.map((call) => call.id)).size === calls.length, - "Tool call IDs must be unique", - ) - .readonly() - .brand<"ToolCalls">(); - -/** One tool invocation proposed by a step. See {@link ToolCallSchema}. */ -export type ToolCall = z.infer; - -/** The tool batch of one step: non-empty, unique call ids. See {@link ToolCallsSchema}. */ -export type ToolCalls = z.infer; - -/** - * Decoded model output of one step. `text` is the narrative the model emitted (possibly empty). - * - * - `answer`: no tool calls; the turn ends `completed`. - * - `tools`: the step proposed a tool batch. The turn asks permission when required, runs the - * batch, and starts the next step with the results. - * - `handoff`: the step hands the turn to `agent`, whose own step starts next with the handoff - * packet the policy's handoff resolver renders. - */ -export const CompletionSchema = z - .discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("answer"), text: z.string() }), - z.strictObject({ kind: z.literal("tools"), text: z.string(), calls: ToolCallsSchema }), - z.strictObject({ kind: z.literal("handoff"), text: z.string(), agent: AgentIdSchema }), - ]) - .readonly(); - -/** Decoded model output of one step. See {@link CompletionSchema}. */ -export type Completion = z.infer; - -/** - * One entry of history or of a model request, in the runtime's own shape. - * - * - `system`: a message with the `system` chat role (not a system notice). - * - `user`: user input. - * - `assistant`: a step's output: narrative text, plus `calls` when it proposed a tool batch. - * - `tool`: the result of the call named by `callId`, as text. - * - * When `parts` is present, `text` must equal the concatenation of its text parts. - */ -export const MessageSchema = z - .discriminatedUnion("role", [ - z.strictObject({ - role: z.literal("system"), - text: z.string(), - parts: ContentPartsSchema.optional(), - }), - z.strictObject({ - role: z.literal("user"), - text: z.string(), - parts: ContentPartsSchema.optional(), - }), - z.strictObject({ - role: z.literal("assistant"), - text: z.string(), - /** The tool batch this step proposed. */ - calls: ToolCallsSchema.optional(), - /** The step that produced this message; set when that step left a provider continuation. */ - owner: CompletionOwnerSchema.optional(), - parts: ContentPartsSchema.optional(), - }), - z.strictObject({ - role: z.literal("tool"), - text: z.string(), - callId: ToolCallIdSchema, - parts: ContentPartsSchema.optional(), - }), - ]) - .refine( - (message) => !message.parts || message.text === partsText(message.parts), - "Message text must equal its text parts", - ) - .readonly(); - -/** One entry of history or of a model request. See {@link MessageSchema}. */ -export type AgentMessage = z.infer; - -/** An ordered message list. Parsing returns a deeply frozen array. */ -export const MessagesSchema = z - .array(MessageSchema) - .readonly() - .transform((messages) => freeze(messages)); - -/** - * The provider's own terminal stop that made a completion fail: an output `token_limit` or a - * `refusal`. `reason` is the provider's stop value, verbatim. Partial output is not kept. - */ -export const ProviderStopSchema = z - .strictObject({ - category: z.enum(["token_limit", "refusal"]), - reason: z.string().min(1), - }) - .readonly(); - -/** - * Serializable description of why an operation, turn or storage request failed. Branch on - * `classification`, `operation` and `providerStop`; `message` is for people, not for parsing. - */ -export const FailureSchema = z - .strictObject({ - /** Human-readable summary. Do not parse it; use `classification`. */ - message: z.string().min(1), - /** - * What kind of failure this is: - * - `execution`: the operation itself threw or rejected. - * - `invalid_input` / `invalid_output`: a tool's arguments or output failed validation. An - * output failure can occur after the tool's external effect. - * - `permission_refused`: the user rejected a tool call. - * - `cancelled`: the user cancelled the turn, the tool batch or a permission request. - * - `timeout`: the operation exceeded its policy deadline; see `timeoutMs`. - * - `interrupted`: the work was cut off by process exit (closed during recovery) or by the - * session stopping. - * - `persistence`: a journal append or load failed. - * - `admission`: an input could not be staged as a journal record (input receipt). - */ - classification: z - .enum([ - "execution", - "invalid_input", - "invalid_output", - "permission_refused", - "cancelled", - "timeout", - "interrupted", - "persistence", - "admission", - ]) - .optional(), - /** - * The operation that failed. `kind` is a turn's child operation (`completion`, `permission`, - * `batch`, `tool`) or session work: `append` and `load` (journal - * storage), `admission` (an input receipt) or `branch` (publishing a fork or compaction). - */ - operation: z - .strictObject({ - id: z.string(), - kind: z.enum([ - "completion", - "permission", - "batch", - "tool", - "append", - "load", - "admission", - "branch", - ]), - sessionId: z.string().optional(), - turnId: z.string().optional(), - toolName: z.string().optional(), - callId: z.string().optional(), - }) - .readonly() - .optional(), - providerStop: ProviderStopSchema.optional(), - /** Free-form name of the stage that failed, such as `validate_input`, `append` or `stage`. */ - phase: z.string().optional(), - /** Structured context added by the layer that reported the failure. */ - details: z.json().optional(), - /** The deadline that expired, in ms. Present with classification `timeout`. */ - timeoutMs: z.number().positive().optional(), - /** The original error in serialized diagnostic form, without stack traces. */ - cause: z.json().optional(), - }) - .readonly(); - -/** Serializable description of a failure. See {@link FailureSchema}. */ -export type Failure = z.infer; - -/** - * Converts a thrown value into a {@link Failure}. - * - * A plain object that already parses as a Failure is kept as is; `context` only fills the fields - * it lacks. Any other value, including every `Error`, becomes a Failure whose message is the - * error's message ("Unknown failure" when it has none). `context` is applied over that, so a - * `context.message` replaces it. A `providerStop` carried by the error is kept, and `cause` is - * always the serialized error. - * - * @throws ZodError when the combined result is not a valid Failure. - */ -export function failure(error: unknown, context: Partial = {}): Failure { - const existing = FailureSchema.safeParse(error); - if (!(error instanceof Error) && existing.success) - return FailureSchema.parse({ ...context, ...existing.data }); - const detail = diagnosticError(error); - // Stacks depend on scheduling and source layout; runtime diagnostics retain them separately. - const serializable = JSON.parse( - JSON.stringify(detail, (key, value) => (key === "stack" ? undefined : value)), - ); - return FailureSchema.parse({ - message: - typeof detail.message === "string" ? detail.message || "Unknown failure" : "Unknown failure", - ...context, - ...(detail.providerStop === undefined - ? {} - : { providerStop: ProviderStopSchema.parse(detail.providerStop) }), - cause: serializable, - }); -} - -/** - * How a turn ended. - * - * - `completed`: the last step answered without tool calls. - * - `aborted`: the user cancelled the turn, its tool batch or a permission request, or a child - * operation was cancelled. `reason` is absent when no failure was recorded. - * - `exhausted`: the step allowance ran out before the next step. - * - `failed`: a child operation failed (including work interrupted by process exit and closed - * during recovery), or the user refused permission; see `error`. - */ -export const OutcomeSchema = z - .discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("completed") }), - z.strictObject({ kind: z.literal("aborted"), reason: FailureSchema.optional() }), - z.strictObject({ kind: z.literal("exhausted") }), - z.strictObject({ kind: z.literal("failed"), error: FailureSchema }), - ]) - .readonly(); - -/** How a turn ended. See {@link OutcomeSchema}. */ -export type Outcome = z.infer; - -/** - * A finished turn in history: the agent that held the turn when it ended (after any handoff), - * every message the turn added, and its outcome. - */ -export const TurnRecordSchema = z - .strictObject({ agent: AgentIdSchema, messages: MessagesSchema, outcome: OutcomeSchema }) - .readonly(); - -/** A finished turn in history. See {@link TurnRecordSchema}. */ -export type TurnRecord = z.infer; - -/** - * Kinds of child operation a turn spawns (child: an operation, not a child session): - * - `completion`: one step: its prompt projection and LLM call; - * - `permission`: asking the user to approve a tool batch; - * - `batch`: running a tool batch; - * - `tool`: running one tool call inside a batch. - */ -export type OperationKind = "completion" | "permission" | "batch" | "tool"; - -/** Identity of one child operation of a turn. Outcomes are matched to the turn by this ref. */ -export type Ref = Readonly<{ kind: K; id: ActorId }>; - -/** - * Creates a frozen {@link Ref}. - * @throws ZodError when `id` is empty. - */ -export const ref = (kind: K, id: string): Ref => - Object.freeze({ kind, id: ActorIdSchema.parse(id) }); - -/** A {@link Ref} of any {@link OperationKind}. */ -export type ChildRef = { [K in OperationKind]: Ref }[OperationKind]; - -/** - * How a child operation settled: `succeeded` with its value, `failed` with a {@link Failure}, - * or `cancelled` before producing a value. - */ -export type Result = - | Readonly<{ kind: "succeeded"; value: T }> - | Readonly<{ kind: "failed"; error: Failure }> - | Readonly<{ kind: "cancelled" }>; - -/** Data of the turn in progress, held by every active turn state and passed to child operations. */ -export type TurnData = Readonly<{ - /** The turn's identity, `/turn/`. */ - id: ActorId; - /** - * Count of child operations spawned so far in this turn (child: an operation, not a child - * session). Not a step counter: it rises two or three times per step. Each child's id is - * `/` at the moment it was spawned. - */ - generation: number; - /** The agent currently holding the turn. A handoff replaces it with the successor. */ - agent: AgentId; - /** Every message this turn has added so far, starting with the user prompt. */ - messages: readonly AgentMessage[]; - /** - * What the default prompt projection sends to the model. `history`: session context, finished - * turns and this turn's messages. `handoff`: the handoff resolver's packet computed from the - * messages up to `at`, plus this turn's messages from `at` on. - */ - view: Readonly<{ kind: "history" }> | Readonly<{ kind: "handoff"; from: AgentId; at: number }>; - /** - * Steps the turn has left. One is used when a step starts, before its prompt is projected; at - * zero the next step does not start and the turn ends `exhausted`. - */ - steps: Steps; -}>; - -/** Returns `turn` with `message` added to its messages. */ -export function appendMessage(turn: TurnData, message: AgentMessage): TurnData { - return { ...turn, messages: [...turn.messages, message] }; -} - -/** - * Input to the in-memory `/agent` runtime. - * - * - `user`: user text. With no active turn it starts one. While a step is in flight it barges in: - * the live completion is cancelled and the step starts again with the text appended. While - * permission or tools are pending it is refused with an error. - * - `abort`: ends the active turn as `aborted`. During a tool batch it cancels the batch and - * keeps the results that already arrived. With no active turn it records an empty aborted turn. - */ -export const UserEventSchema = z.discriminatedUnion("type", [ - z.strictObject({ type: z.literal("user"), text: z.string() }), - z.strictObject({ type: z.literal("abort") }), -]); - -/** Input to the in-memory `/agent` runtime. See {@link UserEventSchema}. */ -export type UserEvent = z.infer; diff --git a/packages/core-agent/effects/README.md b/packages/core-agent/effects/README.md deleted file mode 100644 index 52bc0df9..00000000 --- a/packages/core-agent/effects/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Effects - -An effect is an interaction the runtime had with the outside world, or the lifecycle of an -operation boundary: a child operation, a tool call, a permission question, a provider HTTP request -or stream. Effects are emitted only at operation boundaries — [`host/context.ts`](../host/context.ts) -and [`host/host.ts`](../host/host.ts) (child lifecycle, command dispatch), `host/commands/*` (tool -admission and run, permission decisions, completion usage), and -[`providers/transport.ts`](../providers/transport.ts) / [`providers/stream.ts`](../providers/stream.ts) -(the provider HTTP request/response and stream lifecycle around fetch). Pure decisions and the -journal fold never emit; the fold also runs on load, where nothing happens. - -## `EffectEvent` is a closed, typed union - -`EffectEvent` is a discriminated union keyed by `type`: one member per event this runtime can -raise, each with its own typed payload (correlation IDs plus that event's actual domain data, for -example `usage: CompletionUsage` on `completion.usage.received`). There is no generic fields bag -and no open string escape hatch: narrowing on `type` gives typed fields directly, without a cast. - -Category and level are not part of the payload. They are a logging decision, made once by -`resolveDiagnostic`'s internal table, keyed by `type` and checked by the compiler: the table's type -requires every member of `EffectEvent["type"]`, so adding a union member without a table entry is a -compile error, never a silent fallback. - -## Wire a subscriber - -`EffectEmitter` is `(event: EffectEvent) => unknown`. Supply one through -`SessionBindings.effects`/`ExecutionBindings.effects` for host-level events, and -`TransportBinding.effects` on each provider binding for transport-level events. Both are additive: -the runtime always combines your subscriber with the default `diagnosticsSubscriber()` through -`fanoutEffects`, so binding one never silences logging. A subscriber's failure never affects -execution or the other subscribers: `fanoutEffects` catches a synchronous throw and attaches a -rejection handler to an async subscriber's returned promise, so neither crashes the process or -blocks the rest. - -```ts -import type { EffectEmitter } from "@labkit/core-agent/effects"; - -const usage: EffectEmitter = (event) => { - if (event.type === "completion.usage.received") recordUsage(event.usage); // typed, no cast -}; -``` - -## Logging is a subscriber - -`diagnosticsSubscriber()` is the default: it reproduces exactly the `diagnostic(category, level, -event, fields)` record the event names above have always produced, via `resolveDiagnostic`. It is -not a second emission path for these events. Session lifecycle, journal append/load and ACP adapter -logs are not effect events yet: they still call `diagnostic()` directly. `completion.system_prompt` is emitted at `debug` (previously -`info`); every other event's category, level and fields are unchanged. - -## Side-car HTTP trace - -Full request/response bodies are not part of `EffectEvent`; a subscriber that needs raw traffic -subscribes to `TransportBinding.capture` instead (see -[the environment README](../environment/README.md#retained-provider-traffic)). That keeps -`EffectEvent` a closed set of small, typed payloads and keeps credential-bearing bodies out of the -general subscriber API. diff --git a/packages/core-agent/effects/effects.test.ts b/packages/core-agent/effects/effects.test.ts deleted file mode 100644 index eb29b207..00000000 --- a/packages/core-agent/effects/effects.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { BlobIdSchema } from "../agent/content.ts"; -import { until } from "../agent/test-support.ts"; -import { - ActorIdSchema, - AgentIdSchema, - CompletionSchema, - ref, - StepsSchema, - type ActorId, -} from "../agent/types.ts"; -import { createHost, type HostToolOutcome } from "../host/host.ts"; -import { defineTool } from "../host/ports.ts"; -import { fanoutEffects, resolveDiagnostic, type EffectEvent } from "./index.ts"; - -test("a custom subscriber receives provider usage and tool events with correlation IDs", async () => { - const events: EffectEvent[] = []; - const outcomes: HostToolOutcome[] = []; - const host = createHost( - { - sessionId: "session-effects-test", - agents: new Map([["a", { model: "m", tools: ["echo"] }]]), - tools: new Map([ - ["echo", defineTool({ input: z.object({ text: z.string() }), run: ({ text }) => text })], - ]), - complete: () => ({ - completion: CompletionSchema.parse({ kind: "answer", text: "done" }), - usage: { status: "reported", inputTokens: 12, outputTokens: 3, native: {} }, - }), - effects: (event) => events.push(event), - }, - { - turn: () => {}, - tool: (outcome) => outcomes.push(outcome), - }, - ); - - const turn = { - id: ActorIdSchema.parse("turn"), - agent: AgentIdSchema.parse("a"), - generation: 1, - steps: StepsSchema.parse(1), - messages: [], - view: { kind: "history" as const }, - }; - host.dispatch( - { - type: "turn", - turnId: turn.id, - command: { type: "complete", child: ref("completion", "step"), turn }, - }, - { - prompt: { log: [], turn, agent: { model: "m", tools: [] } }, - projectPrompt: () => ({ messages: [{ role: "user", content: "hi" }], pointers: [] }), - }, - ); - await until(() => events.some((event) => event.type === "completion.usage.received")); - - // Narrowing on `type` gives typed usage fields directly, without a cast. - const usageEvent = events.find((event) => event.type === "completion.usage.received"); - if (usageEvent?.type !== "completion.usage.received") throw new Error("Expected usage event"); - expect(usageEvent.sessionId).toBe("session-effects-test"); - expect(usageEvent.turnId).toBe(turn.id); - expect(usageEvent.childId).toBe("step" as ActorId); - expect(usageEvent.usage.status).toBe("reported"); - if (usageEvent.usage.status !== "reported") throw new Error("Expected reported usage"); - expect(usageEvent.usage.inputTokens).toBe(12); - expect(usageEvent.usage.outputTokens).toBe(3); - - const toolCompletion = CompletionSchema.parse({ - kind: "tools", - text: "work", - calls: [{ id: "c", name: "echo", args: { text: "ok" } }], - }); - if (toolCompletion.kind !== "tools") throw new Error("Expected tools"); - host.dispatch( - { - type: "turn", - turnId: turn.id, - command: { type: "run_tools", child: ref("batch", "batch"), completion: toolCompletion }, - }, - { projectPrompt: () => ({ messages: [], pointers: [] }) }, - ); - await until(() => outcomes.length === 1); - host.releaseTool(outcomes[0]!); - - const toolAdmitted = events.find((event) => event.type === "tool.admitted"); - if (toolAdmitted?.type !== "tool.admitted") throw new Error("Expected tool.admitted event"); - expect(toolAdmitted.sessionId).toBe("session-effects-test"); - expect(toolAdmitted.turnId).toBe(turn.id); - expect(toolAdmitted.batchId).toBe("batch" as ActorId); - expect(toolAdmitted.callId).toBe("c"); - expect(toolAdmitted.toolName).toBe("echo"); - expect(toolAdmitted.permission).toBe("not_required"); - host.close(); -}); - -test("resolveDiagnostic renders every known event type without throwing", () => { - const id = ActorIdSchema.parse("op"); - const trace = { httpRequestId: "req", endpoint: "/x", method: "POST", streaming: false }; - const evidence = { ...trace, httpStatus: 200, providerRequestId: null, durationMs: 1 }; - const completionTrace = { httpRequestId: "req", stream: false, messageCount: 1, toolCount: 0 }; - const permissionContext = { - turnId: id, - batchId: id, - callId: "c", - toolCallId: "t", - name: "echo", - childId: id, - requestId: "r", - toolName: "echo", - }; - // One minimal, valid instance of every EffectEvent member. Removing a case from the internal - // table used by resolveDiagnostic is a compile error (the table type requires every key of - // EffectEvent["type"]); this proves each case also runs without throwing. - const samples: readonly EffectEvent[] = [ - { type: "child.started", childId: id, operation: "tool" }, - { type: "child.failed", childId: id, operation: "tool", phase: "run", error: {} }, - { type: "child.cancelled", childId: id, operation: "tool", phase: "run", error: {} }, - { type: "child.settled", childId: id, operation: "tool", outcome: "succeeded", durationMs: 1 }, - { type: "child.timed_out", reason: { message: "m", classification: "timeout" }, childId: id }, - { type: "child.cancellation_requested", childId: id, operation: "tool" }, - { type: "command.dispatched", turnId: id, childId: id, operation: "cancel" }, - { - type: "permission.grants_cleared", - reason: "r", - policyVersion: 1, - appendId: "a", - toolNames: [], - }, - { type: "tool.released", turnId: id, batchId: id, callId: "c" }, - { type: "host.closed", activeChildren: 0, pendingToolReceipts: 0, pendingGrants: 0 }, - { - type: "completion.system_prompt", - turnId: id, - childId: id, - agentId: AgentIdSchema.parse("a"), - systemMessages: [], - }, - { - type: "completion.usage.received", - turnId: id, - childId: id, - usage: { status: "reported", native: {} }, - }, - { - type: "prompt.media.pointer", - turnId: id, - childId: id, - pointer: { - media: "image/png", - bytes: 1, - blobId: BlobIdSchema.parse("a".repeat(64)), - support: "unsupported", - }, - }, - { type: "tool.locations_failed", toolName: "echo", error: {} }, - { - type: "permission.reused", - ...permissionContext, - scope: "live-session-tool", - source: "remembered", - grantId: "g", - }, - { type: "permission.waiting", ...permissionContext }, - { type: "permission.decided", ...permissionContext, decision: "allow_once", durationMs: 1 }, - { - type: "permission.refused", - ...permissionContext, - toolKind: "other", - rawInput: {}, - durationMs: 1, - }, - { - type: "tool.input_rejected", - turnId: id, - toolName: "echo", - callId: "c", - error: { message: "m" }, - }, - { type: "permission.granted", turnId: id, childId: id, toolName: "echo", grantId: "g" }, - { type: "tool.admitted", toolName: "echo", kind: "other", permission: "approved" }, - { type: "tool.refused", toolName: "echo", permissionChildId: id }, - { type: "tool.locations_resolved", toolName: "echo", locations: [] }, - { type: "tool.awaiting_release", toolName: "echo", outcome: "succeeded" }, - { - type: "tool.status_changed", - toolName: "echo", - previousStatus: "pending", - status: "completed", - rawStatus: "succeeded", - }, - { type: "provider.http.started", trace }, - { type: "provider.http.received", evidence }, - { type: "provider.http.rejected", evidence, errorBody: "bad" }, - { type: "provider.http.completed", evidence, durationMs: 1 }, - { type: "provider.http.cancelled", trace, phase: "fetch", durationMs: 1, error: {} }, - { type: "provider.http.failed", trace, phase: "fetch", durationMs: 1, error: {} }, - { type: "provider.stream.started", context: evidence }, - { - type: "provider.stream.completed", - context: evidence, - bytes: 0, - frames: 0, - deltas: 0, - usage: {}, - terminalEvidence: {}, - durationMs: 1, - }, - { - type: "provider.stream.cancelled", - context: evidence, - bytes: 0, - frames: 0, - deltas: 0, - usage: {}, - terminalEvidence: {}, - bufferedCharacters: 0, - durationMs: 1, - error: {}, - }, - { - type: "provider.stream.failed", - context: evidence, - bytes: 0, - frames: 0, - deltas: 0, - usage: {}, - terminalEvidence: {}, - bufferedCharacters: 0, - durationMs: 1, - error: {}, - }, - { type: "provider.completion.started", trace: completionTrace }, - { - type: "provider.completion.completed", - trace: completionTrace, - terminalEvidence: {}, - durationMs: 1, - completionKind: "answer", - continuation: false, - }, - { - type: "provider.completion.cancelled", - trace: completionTrace, - terminalEvidence: {}, - phase: "transport", - durationMs: 1, - error: {}, - }, - { - type: "provider.completion.failed", - trace: completionTrace, - terminalEvidence: {}, - phase: "transport", - durationMs: 1, - error: {}, - }, - { type: "provider.usage.invalid", trace: completionTrace, terminalEvidence: {}, error: "bad" }, - ]; - for (const sample of samples) { - const record = resolveDiagnostic(sample); - expect(typeof record.category).toBe("string"); - expect(typeof record.level).toBe("string"); - } -}); - -test("an async throwing subscriber does not crash and does not block the other subscribers", async () => { - const rejections: unknown[] = []; - const onRejection = (reason: unknown) => rejections.push(reason); - process.on("unhandledRejection", onRejection); - const received: EffectEvent[] = []; - const emit = fanoutEffects( - () => { - throw new Error("sync subscriber failure"); - }, - async () => { - throw new Error("async subscriber failure"); - }, - (event) => { - received.push(event); - }, - ); - try { - emit({ type: "host.closed", activeChildren: 0, pendingToolReceipts: 0, pendingGrants: 0 }); - // The async subscriber's rejection is caught synchronously inside `emit` (containRejection - // attaches `.catch()` before `emit` returns); flush a couple of microtask ticks so Bun's own - // unhandled-rejection detection, which runs after the current microtask queue drains, would - // have already reported it here if containment had failed. - await Promise.resolve(); - await Promise.resolve(); - expect(received).toHaveLength(1); - expect(rejections).toEqual([]); - } finally { - process.off("unhandledRejection", onRejection); - } -}); diff --git a/packages/core-agent/effects/index.ts b/packages/core-agent/effects/index.ts deleted file mode 100644 index 9f9ffba5..00000000 --- a/packages/core-agent/effects/index.ts +++ /dev/null @@ -1,869 +0,0 @@ -import type { TurnCommand } from "../agent/agent-fsm.ts"; -import type { MediaPointerEvent } from "../agent/prompt.ts"; -import type { ActorId, AgentId, Failure, OperationKind, Result, Steps } from "../agent/types.ts"; -import type { ToolKind, ToolLocation } from "../host/ports.ts"; -import { - diagnostic, - type DiagnosticError, - type DiagnosticFields, - type LogLevel, -} from "../logging/index.ts"; -import type { Policy } from "../policy/policy.ts"; -import type { CompletionUsage } from "../providers/usage.ts"; - -/** Identity shared by every event raised while a permission request or a tool call is live. */ -export type ToolIdentity = Readonly<{ - sessionId?: string; - turnId?: ActorId; - batchId?: ActorId; - callId?: string; - toolCallId?: string; - name?: string; -}>; - -/** `ToolIdentity` plus the fields every permission-decision event carries. */ -export type PermissionContext = ToolIdentity & - Readonly<{ - childId: ActorId; - requestId: string; - toolName: string; - locations?: readonly ToolLocation[]; - }>; - -/** - * One effect: an interaction the runtime had with the outside world, or the lifecycle of an - * operation boundary (a child operation, a tool call, a permission question, a provider HTTP - * request or stream). This is a closed union: every member is one event this runtime can raise, - * with a fully typed payload, so a subscriber narrowing on `type` gets typed fields, never an - * untyped bag. Category and level are not part of the payload; they are a logging decision, made - * by {@link diagnosticsSubscriber}'s table. - * - * Emitted only at operation boundaries: the host's child lifecycle, command dispatch, the tool - * runner, permission decisions, and the provider transport around fetch. Pure decisions and the - * journal fold never emit. - */ -export type EffectEvent = - | Readonly<{ - type: "child.started"; - sessionId?: string; - childId: ActorId; - operation: OperationKind; - }> - | Readonly<{ - type: "child.failed"; - sessionId?: string; - childId: ActorId; - operation: OperationKind; - phase: string; - error: DiagnosticError; - }> - | Readonly<{ - type: "child.cancelled"; - sessionId?: string; - childId: ActorId; - operation: OperationKind; - phase: string; - error: DiagnosticError; - }> - | Readonly<{ - type: "child.settled"; - sessionId?: string; - childId: ActorId; - operation: OperationKind; - outcome: Result["kind"]; - durationMs: number; - error?: DiagnosticError; - }> - | Readonly<{ type: "child.timed_out"; reason: Failure; sessionId?: string; childId: ActorId }> - | Readonly<{ - type: "child.cancellation_requested"; - sessionId?: string; - childId: ActorId; - operation: OperationKind; - reason?: Failure; - }> - | Readonly<{ - type: "command.dispatched"; - sessionId?: string; - turnId: ActorId; - childId: ActorId; - operation: TurnCommand["type"]; - provider?: string; - model?: string; - thinking?: Policy["thinking"]; - thinkingBudgetTokens?: number | null; - stream?: boolean; - maxOutputTokens?: number; - generation?: number; - stepsRemaining?: Steps; - }> - | Readonly<{ - type: "permission.grants_cleared"; - sessionId?: string; - reason: string; - policyVersion: number; - /** The append that applied the change, when a configuration record applied it. */ - appendId?: string; - /** The selection that changed and restored the mode before it applied. */ - selectionId?: string; - toolNames: readonly string[]; - }> - | Readonly<{ - type: "tool.released"; - sessionId?: string; - turnId: ActorId; - batchId: ActorId; - callId: string; - }> - | Readonly<{ - type: "host.closed"; - sessionId?: string; - activeChildren: number; - pendingToolReceipts: number; - pendingGrants: number; - }> - | Readonly<{ - type: "completion.system_prompt"; - sessionId?: string; - turnId: ActorId; - childId: ActorId; - agentId: AgentId; - provider?: string; - model?: string; - systemMessages: readonly Readonly<{ role: "system"; content: string }>[]; - }> - | Readonly<{ - type: "completion.usage.received"; - sessionId?: string; - turnId: ActorId; - childId: ActorId; - model?: string; - usage: CompletionUsage; - }> - | Readonly<{ - type: "prompt.media.pointer"; - sessionId?: string; - turnId: ActorId; - childId: ActorId; - provider?: string; - model?: string; - /** The blob part the step's projection rendered as pointer text for this target. */ - pointer: MediaPointerEvent; - }> - | Readonly< - ToolIdentity & { - type: "tool.locations_failed"; - childId?: ActorId; - toolName: string; - error: DiagnosticError; - } - > - | Readonly< - PermissionContext & { - type: "permission.reused"; - scope: "live-session-tool"; - source: "remembered"; - grantId: string; - } - > - | Readonly<{ type: "permission.waiting" } & PermissionContext> - | Readonly< - PermissionContext & { - type: "permission.decided"; - decision: "cancelled" | "allow_once" | "reject_once"; - durationMs: number; - } - > - | Readonly< - PermissionContext & { - type: "permission.refused"; - toolKind: ToolKind; - rawInput: unknown; - durationMs: number; - } - > - | Readonly<{ - type: "tool.input_rejected"; - sessionId?: string; - turnId: ActorId; - toolCallId?: string; - toolName: string; - callId: string; - error: Failure; - }> - | Readonly<{ - type: "permission.granted"; - sessionId?: string; - turnId: ActorId; - childId: ActorId; - toolName: string; - grantId: string; - policyVersion?: number; - }> - | Readonly< - ToolIdentity & { - type: "tool.admitted"; - toolName: string; - kind: ToolKind; - permission: "refused" | "not_requested_invalid_input" | "approved" | "not_required"; - } - > - | Readonly< - ToolIdentity & { - type: "tool.refused"; - toolName: string; - permissionChildId?: ActorId; - } - > - | Readonly< - ToolIdentity & { - type: "tool.locations_resolved"; - toolName: string; - locations: readonly ToolLocation[]; - } - > - | Readonly< - ToolIdentity & { - type: "tool.awaiting_release"; - toolName: string; - outcome: Result["kind"]; - } - > - | Readonly< - ToolIdentity & { - type: "tool.status_changed"; - toolName: string; - previousStatus: string; - status: string; - rawStatus: string; - error?: DiagnosticError; - } - > - | Readonly<{ - type: "provider.http.started"; - trace: ProviderTrace; - }> - | Readonly<{ type: "provider.http.received"; evidence: ProviderEvidence }> - | Readonly<{ type: "provider.http.rejected"; evidence: ProviderEvidence; errorBody: string }> - | Readonly<{ type: "provider.http.completed"; evidence: ProviderEvidence; durationMs: number }> - | Readonly<{ - type: "provider.http.cancelled"; - trace: ProviderTrace; - phase: string; - durationMs: number; - error: DiagnosticError; - }> - | Readonly<{ - type: "provider.http.failed"; - trace: ProviderTrace; - phase: string; - durationMs: number; - error: DiagnosticError; - }> - | Readonly<{ type: "provider.stream.started"; context: ProviderStreamContext }> - | Readonly<{ - type: "provider.stream.completed"; - context: ProviderStreamContext; - bytes: number; - frames: number; - deltas: number; - lastEvent?: string; - usage: Readonly>; - terminalEvidence: Readonly>; - durationMs: number; - }> - | Readonly<{ - type: "provider.stream.cancelled"; - context: ProviderStreamContext; - bytes: number; - frames: number; - deltas: number; - lastEvent?: string; - usage: Readonly>; - terminalEvidence: Readonly>; - bufferedCharacters: number; - durationMs: number; - error: DiagnosticError; - }> - | Readonly<{ - type: "provider.stream.failed"; - context: ProviderStreamContext; - bytes: number; - frames: number; - deltas: number; - lastEvent?: string; - usage: Readonly>; - terminalEvidence: Readonly>; - bufferedCharacters: number; - durationMs: number; - error: DiagnosticError; - }> - | Readonly<{ type: "provider.completion.started"; trace: CompletionTrace }> - | Readonly<{ - type: "provider.completion.completed"; - trace: CompletionTrace; - terminalEvidence: Readonly>; - durationMs: number; - completionKind: string; - continuation: boolean; - }> - | Readonly<{ - type: "provider.completion.cancelled"; - trace: CompletionTrace; - terminalEvidence: Readonly>; - phase: string; - durationMs: number; - error: DiagnosticError; - }> - | Readonly<{ - type: "provider.completion.failed"; - trace: CompletionTrace; - terminalEvidence: Readonly>; - phase: string; - durationMs: number; - error: DiagnosticError; - }> - | Readonly<{ - type: "provider.usage.invalid"; - trace: CompletionTrace; - terminalEvidence: Readonly>; - error: string; - }>; - -/** Correlation and request shape known before a provider HTTP call is dispatched. */ -export type ProviderTrace = Readonly<{ - sessionId?: string; - turnId?: string; - childId?: string; - generation?: number; - requestId?: string; - httpRequestId: string; - provider?: string; - model?: string; - toolCallId?: string; - endpoint: string; - method: string; - streaming: boolean; -}>; - -/** {@link ProviderTrace} plus the response metadata known once headers arrive. */ -export type ProviderEvidence = ProviderTrace & - Readonly<{ httpStatus: number; providerRequestId: string | null; durationMs: number }>; - -/** - * What a stream assembler is actually given: normally the full {@link ProviderEvidence} of the - * response that opened the stream, but a caller that assembles a stream without going through - * `httpTransport` (for example a test fixture) may supply none of it. - */ -export type ProviderStreamContext = Partial; - -/** Correlation and request shape for one `bindProviders(...).complete` call. */ -export type CompletionTrace = Readonly<{ - sessionId?: string; - turnId?: string; - childId?: string; - generation?: number; - requestId?: string; - httpRequestId: string; - provider?: string; - model?: string; - toolCallId?: string; - stream: boolean; - thinking?: unknown; - thinkingBudgetTokens?: number | null; - maxOutputTokens?: number; - messageCount: number; - toolCount: number; - profile?: string; - wireModel?: string; -}>; - -/** - * Cross-cutting subscriber: logging, traffic capture, usage/cost accounting, auditing, hooks. - * Adding one changes no core code; supply it through `ExecutionBindings.effects` (host-level - * events) and `TransportBinding.effects` (provider transport events). Best-effort: a throwing - * subscriber must not affect execution, so callers should combine subscribers with - * {@link fanoutEffects} rather than let one exception drop the rest. - */ -export type EffectEmitter = (event: EffectEvent) => unknown; - -/** An emitter that discards every event; the default when no subscriber is bound. */ -export const noopEffects: EffectEmitter = () => {}; - -/** - * Contains a subscriber's rejection so it never surfaces as an unhandled promise rejection. A - * synchronous subscriber never returns a thenable, so this is a no-op for it; an async subscriber - * that throws still runs to completion elsewhere, its rejection just never propagates here. - */ -function containRejection(result: unknown): void { - if (result && typeof (result as PromiseLike).then === "function") - Promise.resolve(result).catch(() => { - // Best-effort, like diagnostic(): an async subscriber's rejection never affects execution. - }); -} - -/** Runs one subscriber for one event; its failure, sync or async, never affects execution. */ -function callEmitter(emitter: EffectEmitter, event: EffectEvent): void { - try { - containRejection(emitter(event)); - } catch { - // Best-effort, like diagnostic(): a subscriber failure never affects execution. - } -} - -/** Combines subscribers so each event reaches every one of them once, in order. */ -export function fanoutEffects(...emitters: readonly (EffectEmitter | undefined)[]): EffectEmitter { - const live = emitters.filter((emitter): emitter is EffectEmitter => !!emitter); - if (live.length === 0) return noopEffects; - if (live.length === 1) return (event) => callEmitter(live[0]!, event); - return (event) => { - for (const emitter of live) callEmitter(emitter, event); - }; -} - -/** What the default subscriber logs for one event: category, level and the rendered fields. */ -export type DiagnosticRecord = Readonly<{ - category: string; - level: LogLevel; - fields: DiagnosticFields; -}>; - -/** - * Category, level and rendered fields for every known event, keyed by `type`. A missing or - * misspelled key is a compile error (`Record` requires every member of `EffectEvent["type"]`), - * so adding a union member without a table entry never silently falls back to a default. This is - * the only place a log level or category is chosen; events themselves carry only domain data. - */ -const diagnosticTable: { - [K in EffectEvent["type"]]: (event: Extract) => DiagnosticRecord; -} = { - "child.started": (e) => ({ - category: e.operation === "completion" ? "provider" : "host", - level: "debug", - fields: { sessionId: e.sessionId, childId: e.childId, operation: e.operation }, - }), - "child.failed": (e) => ({ - category: e.operation === "completion" ? "provider" : "host", - level: "warning", - fields: { sessionId: e.sessionId, childId: e.childId, phase: e.phase, error: e.error }, - }), - "child.cancelled": (e) => ({ - category: e.operation === "completion" ? "provider" : "host", - level: "info", - fields: { sessionId: e.sessionId, childId: e.childId, phase: e.phase, error: e.error }, - }), - "child.settled": (e) => ({ - category: e.operation === "completion" ? "provider" : "host", - level: e.outcome === "failed" ? "warning" : "debug", - fields: { - sessionId: e.sessionId, - childId: e.childId, - operation: e.operation, - outcome: e.outcome, - durationMs: e.durationMs, - ...(e.error ? { error: e.error } : {}), - }, - }), - "child.timed_out": (e) => ({ - category: "host", - level: "warning", - fields: { ...e.reason, sessionId: e.sessionId, childId: e.childId }, - }), - "child.cancellation_requested": (e) => ({ - category: "host", - level: "debug", - fields: { - sessionId: e.sessionId, - childId: e.childId, - operation: e.operation, - reason: e.reason, - }, - }), - "command.dispatched": (e) => ({ - category: "host", - level: "debug", - fields: { - sessionId: e.sessionId, - turnId: e.turnId, - childId: e.childId, - operation: e.operation, - provider: e.provider, - model: e.model, - thinking: e.thinking, - thinkingBudgetTokens: e.thinkingBudgetTokens, - stream: e.stream, - maxOutputTokens: e.maxOutputTokens, - ...(e.generation !== undefined - ? { generation: e.generation, stepsRemaining: e.stepsRemaining } - : {}), - }, - }), - "permission.grants_cleared": (e) => ({ - category: "host", - level: "info", - fields: { - sessionId: e.sessionId, - reason: e.reason, - policyVersion: e.policyVersion, - ...(e.appendId !== undefined ? { appendId: e.appendId } : {}), - ...(e.selectionId !== undefined ? { selectionId: e.selectionId } : {}), - toolNames: e.toolNames, - }, - }), - "tool.released": (e) => ({ - category: "host", - level: "debug", - fields: { sessionId: e.sessionId, turnId: e.turnId, batchId: e.batchId, callId: e.callId }, - }), - "host.closed": (e) => ({ - category: "host", - level: "debug", - fields: { - sessionId: e.sessionId, - activeChildren: e.activeChildren, - pendingToolReceipts: e.pendingToolReceipts, - pendingGrants: e.pendingGrants, - }, - }), - "completion.system_prompt": (e) => ({ - category: "provider", - level: "debug", - fields: { - sessionId: e.sessionId, - turnId: e.turnId, - childId: e.childId, - agentId: e.agentId, - provider: e.provider, - model: e.model, - message: "System instructions supplied to this completion, in order", - systemMessages: e.systemMessages, - }, - }), - "completion.usage.received": (e) => ({ - category: "provider", - level: "info", - fields: { - sessionId: e.sessionId, - turnId: e.turnId, - childId: e.childId, - model: e.model, - usage: e.usage, - message: "Completion response usage validated; awaiting runtime settlement", - }, - }), - "prompt.media.pointer": (e) => ({ - category: "prompt", - level: "debug", - fields: { - sessionId: e.sessionId, - turnId: e.turnId, - childId: e.childId, - provider: e.provider, - model: e.model, - media: e.pointer.media, - bytes: e.pointer.bytes, - support: e.pointer.support, - blobId: e.pointer.blobId, - }, - }), - "tool.locations_failed": (e) => ({ - category: "host", - level: "warning", - fields: { - ...(e.sessionId !== undefined ? { sessionId: e.sessionId } : {}), - ...(e.turnId !== undefined ? { turnId: e.turnId } : {}), - ...(e.batchId !== undefined ? { batchId: e.batchId } : {}), - ...(e.callId !== undefined ? { callId: e.callId } : {}), - ...(e.toolCallId !== undefined ? { toolCallId: e.toolCallId } : {}), - ...(e.name !== undefined ? { name: e.name } : {}), - toolName: e.toolName, - error: e.error, - ...(e.childId !== undefined ? { childId: e.childId } : {}), - }, - }), - "permission.reused": (e) => ({ - category: "host", - level: "info", - fields: { - ...toolIdentityFields(e), - childId: e.childId, - requestId: e.requestId, - toolName: e.toolName, - locations: e.locations, - scope: e.scope, - source: e.source, - grantId: e.grantId, - reason: "User previously approved this tool for all arguments in this live session", - }, - }), - "permission.waiting": (e) => ({ - category: "host", - level: "info", - fields: { - ...toolIdentityFields(e), - childId: e.childId, - requestId: e.requestId, - toolName: e.toolName, - locations: e.locations, - reason: "Tool execution requires user approval; batch execution is blocked", - }, - }), - "permission.decided": (e) => ({ - category: "host", - level: "info", - fields: { - ...toolIdentityFields(e), - childId: e.childId, - requestId: e.requestId, - toolName: e.toolName, - locations: e.locations, - decision: e.decision, - durationMs: e.durationMs, - }, - }), - "permission.refused": (e) => ({ - category: "host", - level: "warning", - fields: { - ...toolIdentityFields(e), - childId: e.childId, - requestId: e.requestId, - toolName: e.toolName, - locations: e.locations, - operation: "tool_execution", - outcome: "blocked", - decision: "reject_once", - reasonCode: "permission_refused", - reason: - "User refused permission for a model-requested tool; the model receives a permission-refused result for this call and the turn continues", - toolKind: e.toolKind, - rawInput: e.rawInput, - durationMs: e.durationMs, - }, - }), - "tool.input_rejected": (e) => ({ - category: "host", - level: "warning", - fields: { - sessionId: e.sessionId, - turnId: e.turnId, - toolCallId: e.toolCallId, - toolName: e.toolName, - callId: e.callId, - error: e.error, - consequence: - "Tool will not execute or request approval; validation error will be committed as a tool result for the model to correct", - }, - }), - "permission.granted": (e) => ({ - category: "host", - level: "info", - fields: { - sessionId: e.sessionId, - turnId: e.turnId, - childId: e.childId, - toolName: e.toolName, - grantId: e.grantId, - scope: "live-session-tool", - policyVersion: e.policyVersion, - reason: - "User approved this tool for all arguments until this session closes, tool scope changes, or permissions are explicitly reset", - }, - }), - "tool.admitted": (e) => ({ - category: "host", - level: "debug", - fields: { - ...toolIdentityFields(e), - toolName: e.toolName, - kind: e.kind, - permission: e.permission, - }, - }), - "tool.refused": (e) => ({ - category: "host", - level: "info", - fields: { - ...toolIdentityFields(e), - toolName: e.toolName, - permissionChildId: e.permissionChildId, - reason: - "Not run: user refused permission (see permission.refused); the model receives a permission-refused result", - }, - }), - "tool.locations_resolved": (e) => ({ - category: "host", - level: "debug", - fields: { ...toolIdentityFields(e), toolName: e.toolName, locations: e.locations }, - }), - "tool.awaiting_release": (e) => ({ - category: "host", - level: "debug", - fields: { - ...toolIdentityFields(e), - toolName: e.toolName, - outcome: e.outcome, - reason: "Result reported; awaiting caller release (session journal receipt when durable)", - }, - }), - "tool.status_changed": (e) => ({ - category: "host", - level: e.rawStatus === "failed" ? "warning" : "debug", - fields: { - ...toolIdentityFields(e), - toolName: e.toolName, - previousStatus: e.previousStatus, - status: e.status, - ...(e.error ? { error: e.error } : {}), - }, - }), - "provider.http.started": (e) => ({ category: "provider", level: "debug", fields: e.trace }), - "provider.http.received": (e) => ({ category: "provider", level: "debug", fields: e.evidence }), - "provider.http.rejected": (e) => ({ - category: "provider", - level: "warning", - fields: { ...e.evidence, errorBody: e.errorBody }, - }), - "provider.http.completed": (e) => ({ - category: "provider", - level: "debug", - fields: { ...e.evidence, durationMs: e.durationMs }, - }), - "provider.http.cancelled": (e) => ({ - category: "provider", - level: "info", - fields: { ...e.trace, phase: e.phase, durationMs: e.durationMs, error: e.error }, - }), - "provider.http.failed": (e) => ({ - category: "provider", - level: "warning", - fields: { ...e.trace, phase: e.phase, durationMs: e.durationMs, error: e.error }, - }), - "provider.stream.started": (e) => ({ category: "provider", level: "debug", fields: e.context }), - "provider.stream.completed": (e) => ({ - category: "provider", - level: "debug", - fields: { - ...e.context, - bytes: e.bytes, - frames: e.frames, - deltas: e.deltas, - lastEvent: e.lastEvent, - usage: e.usage, - ...e.terminalEvidence, - durationMs: e.durationMs, - }, - }), - "provider.stream.cancelled": (e) => ({ - category: "provider", - level: "info", - fields: { - ...e.context, - bytes: e.bytes, - frames: e.frames, - deltas: e.deltas, - lastEvent: e.lastEvent, - usage: e.usage, - ...e.terminalEvidence, - bufferedCharacters: e.bufferedCharacters, - durationMs: e.durationMs, - error: e.error, - }, - }), - "provider.stream.failed": (e) => ({ - category: "provider", - level: "warning", - fields: { - ...e.context, - bytes: e.bytes, - frames: e.frames, - deltas: e.deltas, - lastEvent: e.lastEvent, - usage: e.usage, - ...e.terminalEvidence, - bufferedCharacters: e.bufferedCharacters, - durationMs: e.durationMs, - error: e.error, - }, - }), - "provider.completion.started": (e) => ({ category: "provider", level: "debug", fields: e.trace }), - "provider.completion.completed": (e) => ({ - category: "provider", - level: "info", - fields: { - ...e.trace, - durationMs: e.durationMs, - ...e.terminalEvidence, - completionKind: e.completionKind, - continuation: e.continuation, - }, - }), - "provider.completion.cancelled": (e) => ({ - category: "provider", - level: "info", - fields: { - ...e.trace, - ...e.terminalEvidence, - phase: e.phase, - durationMs: e.durationMs, - error: e.error, - }, - }), - "provider.completion.failed": (e) => ({ - category: "provider", - level: "warning", - fields: { - ...e.trace, - ...e.terminalEvidence, - phase: e.phase, - durationMs: e.durationMs, - error: e.error, - }, - }), - "provider.usage.invalid": (e) => ({ - category: "provider", - level: "warning", - fields: { - ...e.trace, - ...e.terminalEvidence, - error: e.error, - message: - "Provider returned invalid token accounting; completion remains usable, accounting is retained without normalized counts", - }, - }), -}; - -/** Shared rendering for the `ToolIdentity`-based events, omitting keys that are truly absent. */ -function toolIdentityFields(identity: ToolIdentity): DiagnosticFields { - return { - ...(identity.sessionId !== undefined ? { sessionId: identity.sessionId } : {}), - ...(identity.turnId !== undefined ? { turnId: identity.turnId } : {}), - ...(identity.batchId !== undefined ? { batchId: identity.batchId } : {}), - ...(identity.callId !== undefined ? { callId: identity.callId } : {}), - ...(identity.toolCallId !== undefined ? { toolCallId: identity.toolCallId } : {}), - ...(identity.name !== undefined ? { name: identity.name } : {}), - }; -} - -/** - * Category, level and rendered fields for one event, looked up in {@link diagnosticTable}. This is - * the only place a log level or category is chosen; events themselves carry only domain data. - * Exported so a test can prove the table stays exhaustive over every {@link EffectEvent} member - * without duplicating it: `diagnosticTable`'s own type already fails to compile if a case is - * missing, and calling this for a representative instance of every member proves it also runs. - */ -export function resolveDiagnostic(event: EffectEvent): DiagnosticRecord { - return (diagnosticTable[event.type] as (event: EffectEvent) => DiagnosticRecord)(event); -} - -/** - * The default subscriber: produces the same `diagnostic()` records the runtime always has, from - * the emitted events, via {@link resolveDiagnostic}. Logging is a consumer of effects, not a - * second emission path. - */ -export function diagnosticsSubscriber(): EffectEmitter { - return (event) => { - const record = resolveDiagnostic(event); - diagnostic(record.category, record.level, event.type, record.fields); - }; -} diff --git a/packages/core-agent/environment/README.md b/packages/core-agent/environment/README.md deleted file mode 100644 index 9701575d..00000000 --- a/packages/core-agent/environment/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Bind a session to an application - -The environment owns resources that outlive a single turn: event sources, rendering, credentials, -storage, and diagnostic sinks. Core owns the execution loop. An application should translate user -intent into session events and display their outcomes, not poll turn phases to decide when to call -a model or execute a tool. - -## Keep the input channel open during work - -`startEnvironment(options, environment)` creates a session; `runEnvironment(session, environment)` -binds an existing or restored one. An `Environment` provides an `AsyncIterable` and a -renderer. The loop waits for admission, not terminal settlement, so it can read abort/replacement -input while a model or tool is active. - -Render updates distinguish snapshots, receipts, and settlements. Use receipts to show accepted -input and settlements to show how work ended. A branch settlement carries the published child; -switch the UI to that session if desired rather than copying its state into another execution loop. - -Source exhaustion closes the session and cancels outstanding work. A generator that yields one -prompt and returns is therefore not “ask and wait for the answer.” Keep the source open until its -matching settlement arrives. An explicit close event has the same lifetime consequence. Closing a -session does not close caller-owned persistence or global logging. - -## Retained provider traffic - -Use full capture when you need to inspect exactly what reached a provider, especially malformed -JSON or a truncated stream. The journal records admitted runtime data; it cannot substitute for a -raw response that failed admission. Diagnostic logs retain causes, identities, and the full ordered system messages in -`completion.system_prompt` at DEBUG. Complete user/file bodies and provider responses require capture. - -```ts -import { createProviderCapture } from "@labkit/core-agent/environment/provider-capture"; - -const run = await createProviderCapture(".session-artifacts/my-run"); -// Put run.capture in each provider's transport.capture binding. -// After all owned sessions/requests have settled or closed: -await run.flush(); -console.log(run.directory); // Open README.md and manifest.json here. -``` - -Every call gets linked request/response files and a report of model, counts, byte sizes, and repeated -message content. Requests are saved before dispatch; response text is retained before JSON parsing, -and partial SSE text is retained as it arrives. Each run has a unique directory, so a failed run -cannot inherit an earlier success report. Configured credential values are redacted. Files are -replaced by rename after a complete temporary write; a process stopping mid-write does not truncate -the previously saved file. A forced-termination test verifies retained request, partial response, -manifest, and logs without graceful shutdown. This is a process-failure guarantee, not a power-loss -fsync guarantee. An interrupted call remains labeled in progress when no terminal event was observed. - -Bind the same capture to model transports used inside tools and pass their `ToolRunContext` -correlation. This distinguishes a growing coordinator history from independent extraction calls. -Scripted completion-port invocations are labeled separately: they do not establish HTTP evidence. - -`createProviderCapture` retains full content until the application removes it; choose quotas and -retention for a one-off diagnostic run. A capture write failure fails the operation instead of -claiming evidence was retained. This is intentionally stronger than best-effort diagnostic -logging. Configure [bounded lifecycle logs](../logging/README.md) separately; those remain -necessary when capture is disabled. - -### A long-running launcher's side-car trace - -`openHttpTrace(root, options?)` is for a process that opens many sessions over its lifetime, such -as the ACP workspace launcher or the web server. `root` must be an absolute path (it is also -`resolve()`d; a relative path, including `.`, throws) — never trust an environment variable's -value as a directory to prune blindly. It prunes recognized capture run directories under `root` -beyond the newest `options.keep` (default 20, mirroring the ACP launcher's own log retention), -skipping any run whose name embeds a still-alive process ID, then starts one fresh run: a -directory holding `manifest.jsonl`, one JSON line appended per request/response event, never -rewritten, so recording a call costs one append rather than a rewrite of every call recorded so -far. Bind its `capture` to every provider's `transport.capture` for that process; every session -opened from it shares the current run until it rotates to a fresh directory after -`options.maxCalls` calls (default 2000) or once its manifest would exceed `options.maxBytes` -(default 64 MiB), pruning old runs again at each rotation. Only directories `pruneProviderCaptures` -recognizes as capture runs (a run-shaped name, a directory, holding `manifest.json` or -`manifest.jsonl`) are ever candidates for removal; anything else under `root` is left untouched. -Call `pruneProviderCaptures(root, keep)` directly to reclaim space without opening a new run. diff --git a/packages/core-agent/environment/environment.test.ts b/packages/core-agent/environment/environment.test.ts deleted file mode 100644 index 5af09b44..00000000 --- a/packages/core-agent/environment/environment.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { deferred } from "../agent/test-support.ts"; -import type { EnvEvent } from "../session/events.ts"; -import { createSession } from "../session/session-runtime.ts"; -import { createMemoryPersistence } from "../session/testing/memory-persistence.ts"; -import { startEnvironment, type EnvironmentUpdate } from "./environment.ts"; - -test("event loop receives abort during an unsettled completion and renders correlated outcomes", async () => { - const started = deferred(); - const pending = deferred(); - const updates: EnvironmentUpdate[] = []; - async function* events(): AsyncGenerator { - yield { type: "system", inputs: ["instruction"] }; - yield { type: "user", text: "go" }; - await started.promise; - yield { type: "abort" }; - yield { type: "close" }; - } - await startEnvironment( - { - persistence: createMemoryPersistence(), - configuration: { agent: "a", agents: new Map([["a", { model: "m" }]]), steps: 2 }, - bindings: { - complete: () => { - started.resolve(); - return pending.promise; - }, - }, - }, - { - events: events(), - render: (update) => { - updates.push(update); - }, - }, - ); - expect( - updates.some( - (update) => - update.kind === "settled" && - update.outcome.kind === "terminal" && - update.outcome.record.outcome.kind === "aborted", - ), - ).toBe(true); - expect( - updates.some( - (update) => update.kind === "receipt" && update.receipt.kind === "close_acknowledged", - ), - ).toBe(true); - expect(updates.some((update) => update.kind === "snapshot")).toBe(true); -}); -test("one public boundary validates events, publishes branches, and rejects child forgery", async () => { - const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { agent: "a", agents: new Map([["a", { model: "m" }]]), steps: 1 }, - bindings: { complete: () => ({ kind: "answer", text: "ok" }) }, - }); - expect(() => session.dispatch({ type: "child", event: {} })).toThrow(); - expect((await session.fire({ type: "policy", patch: { steps: 2 } })).kind).toBe("accepted"); - const branch = session.dispatch({ type: "compact", context: [] }); - expect((await branch.accepted).kind).toBe("accepted"); - const result = await branch.settled; - expect(result.kind).toBe("branch"); - if (result.kind === "branch") { - expect(result.session.snapshot.durable.conversation.origin.kind).toBe("compaction"); - await result.session.close(); - } - await session.close(); -}); diff --git a/packages/core-agent/environment/environment.ts b/packages/core-agent/environment/environment.ts deleted file mode 100644 index 2965beee..00000000 --- a/packages/core-agent/environment/environment.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { EnvEvent } from "../session/events.ts"; -import type { SessionState } from "../session/session-fsm.ts"; -import { - createSession, - type EnvReceipt, - type EnvSettlement, - type SessionOptions, - type SessionRuntime, -} from "../session/session-runtime.ts"; - -export type EnvironmentUpdate = - | Readonly<{ kind: "snapshot"; snapshot: SessionState }> - | Readonly<{ kind: "receipt"; event: EnvEvent; receipt: EnvReceipt }> - | Readonly<{ kind: "settled"; event: EnvEvent; outcome: EnvSettlement }>; - -export type Environment = Readonly<{ - events: AsyncIterable; - render: (update: EnvironmentUpdate) => void | Promise; -}>; -/** The source owns interaction timing. This loop only waits for admission, never turn completion. */ -export async function runEnvironment(session: SessionRuntime, environment: Environment) { - const settlements = new Set>(); - let settlementError: unknown; - let rendering = Promise.resolve(); - const render = (update: EnvironmentUpdate) => { - const next = rendering.then(() => environment.render(update)); - rendering = next.catch(() => {}); - return next; - }; - try { - for await (const event of environment.events) { - const handle = session.dispatch(event); - const settled = handle.settled.then((outcome) => render({ kind: "settled", event, outcome })); - settlements.add(settled); - void settled.then( - () => settlements.delete(settled), - (error) => { - settlements.delete(settled); - settlementError ??= error; - }, - ); - await render({ kind: "receipt", event, receipt: await handle.accepted }); - if (event.type === "close") break; - } - } finally { - await session.close(); - await Promise.allSettled(settlements); - await rendering; - } - if (settlementError) throw settlementError; -} -/** Bind before construction so initial commits are observable too. No phase decisions live here. */ -export async function startEnvironment(options: SessionOptions, environment: Environment) { - let observing = Promise.resolve(); - const originalObserve = options.bindings.observe; - const originalRender = environment.render; - const render = (update: EnvironmentUpdate) => { - const next = observing.then(() => originalRender(update)); - observing = next.catch(() => {}); - return next; - }; - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - observe: (snapshot) => { - try { - const result = originalObserve?.(snapshot); - if (result instanceof Promise) void result.catch(() => {}); - } catch { - /* Observer isolation. */ - } - void render({ kind: "snapshot", snapshot }).catch(() => {}); - }, - }, - }); - try { - await runEnvironment(session, { ...environment, render }); - } finally { - await observing; - } - return session; -} diff --git a/packages/core-agent/environment/http-trace.test.ts b/packages/core-agent/environment/http-trace.test.ts deleted file mode 100644 index 31278d80..00000000 --- a/packages/core-agent/environment/http-trace.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { openHttpTrace, pruneProviderCaptures } from "./provider-capture.ts"; - -async function tempRoot() { - return mkdtemp(join(tmpdir(), "http-trace-test-")); -} - -test("pruneProviderCaptures never removes anything under root that is not a recognized capture run", async () => { - const root = await tempRoot(); - try { - await writeFile(join(root, "notes.txt"), "keep me"); - await mkdir(join(root, "unrelated-directory")); - await writeFile(join(root, "unrelated-directory", "data.json"), "{}"); - // Looks like a run name but has no manifest: not a recognized capture run. - await mkdir(join(root, "11111111-1111-4111-8111-111111111111")); - await pruneProviderCaptures(root, 0); - const remaining = await readdir(root); - expect(remaining.sort()).toEqual( - ["11111111-1111-4111-8111-111111111111", "notes.txt", "unrelated-directory"].sort(), - ); - expect(await readdir(join(root, "unrelated-directory"))).toEqual(["data.json"]); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test("pruneProviderCaptures never removes a run directory owned by a live process", async () => { - const root = await tempRoot(); - try { - const liveRun = `${process.pid}-22222222-2222-4222-8222-222222222222`; - await mkdir(join(root, liveRun)); - await writeFile(join(root, liveRun, "manifest.jsonl"), ""); - await pruneProviderCaptures(root, 0); - expect(await readdir(root)).toEqual([liveRun]); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test("pruneProviderCaptures removes a recognized run whose owning process no longer exists", async () => { - const root = await tempRoot(); - try { - // A PID no real process holds for the duration of this test. - const deadRun = "999999-33333333-3333-4333-8333-333333333333"; - await mkdir(join(root, deadRun)); - await writeFile(join(root, deadRun, "manifest.jsonl"), ""); - await pruneProviderCaptures(root, 0); - expect(await readdir(root)).toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test("openHttpTrace rejects a relative directory", async () => { - await expect(openHttpTrace(".")).rejects.toThrow("absolute path"); - await expect(openHttpTrace("relative/trace")).rejects.toThrow("absolute path"); -}); - -test("openHttpTrace appends one JSONL line per event and rotates after maxCalls", async () => { - const root = await tempRoot(); - try { - const trace = await openHttpTrace(root, { maxCalls: 1 }); - const first = trace.directory(); - await trace.capture({ kind: "http_request", httpRequestId: "a", body: "req-a" }); - await trace.capture({ kind: "http_response", httpRequestId: "a", body: "res-a" }); - expect(trace.directory()).toBe(first); - // A second distinct call exceeds maxCalls=1, so it starts a new run directory. - await trace.capture({ kind: "http_request", httpRequestId: "b", body: "req-b" }); - const second = trace.directory(); - expect(second).not.toBe(first); - await trace.close(); - - const firstManifest = (await Bun.file(join(first, "manifest.jsonl")).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(firstManifest).toHaveLength(2); - expect(firstManifest[0]).toMatchObject({ kind: "http_request", httpRequestId: "a" }); - const secondManifest = (await Bun.file(join(second, "manifest.jsonl")).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(secondManifest).toHaveLength(1); - expect(secondManifest[0]).toMatchObject({ kind: "http_request", httpRequestId: "b" }); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); diff --git a/packages/core-agent/environment/index.ts b/packages/core-agent/environment/index.ts deleted file mode 100644 index 9aef4e36..00000000 --- a/packages/core-agent/environment/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./environment.ts"; -export { EnvEventSchema, type EnvEvent } from "../session/events.ts"; diff --git a/packages/core-agent/environment/provider-capture.test.ts b/packages/core-agent/environment/provider-capture.test.ts deleted file mode 100644 index 104f4092..00000000 --- a/packages/core-agent/environment/provider-capture.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { resolve } from "node:path"; - -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { deferred } from "../session/test-support.ts"; -import { createProviderCapture } from "./provider-capture.ts"; - -test("actual HTTP request and partial response survive SIGKILL without shutdown or flush", async () => { - const partial = 'data: {"choices":[{"index":0,"delta":{"content":"partial evidence"}}]}\n\n'; - let requests = 0; - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch() { - requests++; - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(partial)); - }, - }), - { - headers: { - "Content-Type": "text/event-stream", - "x-request-id": "crashed-stream-request", - }, - }, - ); - }, - }); - const ready = deferred<{ directory: string; runId: string; httpRequestId: string }>(); - const root = resolve(".session-artifacts/process-failure"); - const child = Bun.spawn( - [ - process.execPath, - resolve(import.meta.dir, "testing/capture-process.ts"), - root, - `http://127.0.0.1:${server.port}/v1`, - ], - { - stdout: "pipe", - stderr: "pipe", - ipc(message) { - ready.resolve(message); - }, - }, - ); - const stderr = new Response(child.stderr).text(); - const watchdog = setTimeout(() => child.kill("SIGKILL"), 5000); - try { - const evidence = await Promise.race([ - ready.promise, - child.exited.then(async (code) => { - throw new Error( - `Capture child exited before retaining evidence (${code}): ${await stderr}`, - ); - }), - ]); - child.kill("SIGKILL"); - await child.exited; - expect(child.signalCode).toBe("SIGKILL"); - expect(requests).toBe(1); - const manifestText = await Bun.file(`${evidence.directory}/manifest.json`).text(); - const manifest = JSON.parse(manifestText); - expect(manifest.runId).toBe(evidence.runId); - expect(manifest.calls).toHaveLength(1); - const call = manifest.calls[0]; - expect(call).toMatchObject({ - evidence: "actual_http", - model: "crash-model", - httpStatus: 200, - providerRequestId: "crashed-stream-request", - httpRequestId: evidence.httpRequestId, - }); - expect(call.sessionId).toBeString(); - expect(call.turnId).toBeString(); - expect(call.childId).toBeString(); - expect(call.outcome).not.toBe("succeeded"); - expect(await Bun.file(`${evidence.directory}/${call.responseFile}`).text()).toBe(partial); - const request = await Bun.file(`${evidence.directory}/${call.requestFile}`).text(); - expect(JSON.parse(request)).toMatchObject({ model: "crash-model", stream: true }); - expect(request).toContain("Retain this request"); - expect(manifestText + request + partial).not.toContain("CRASH_CAPTURE_SECRET"); - expect(await Bun.file(`${evidence.directory}/README.md`).text()).toContain(call.responseFile); - const logs = await Bun.file(`${evidence.directory}/diagnostics.jsonl`).text(); - expect(logs).toContain(call.childId); - expect(logs).not.toContain('"event":"turn.settled"'); - const nextRun = await createProviderCapture(root); - expect(nextRun.directory).not.toBe(evidence.directory); - expect(await Bun.file(`${evidence.directory}/manifest.json`).text()).toBe(manifestText); - await Bun.write( - `${evidence.directory}/process-outcome.json`, - JSON.stringify( - { signal: child.signalCode, requests, flushedOnShutdown: false, runId: evidence.runId }, - null, - 2, - ), - ); - } finally { - clearTimeout(watchdog); - if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); - await child.exited; - server.stop(true); - } -}, 10000); diff --git a/packages/core-agent/environment/provider-capture.ts b/packages/core-agent/environment/provider-capture.ts deleted file mode 100644 index 18839090..00000000 --- a/packages/core-agent/environment/provider-capture.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { appendFile, mkdir, readdir, rename, rm, stat } from "node:fs/promises"; -import { isAbsolute, join, resolve } from "node:path"; - -import { redactDiagnostics } from "../logging/index.ts"; -import type { ProviderCapture } from "../providers/transport.ts"; - -/** One caller-owned capture run: retained evidence directory plus its capture sink. */ -export type ProviderCaptureRun = Readonly<{ - runId: string; - directory: string; - capture: ProviderCapture; - flush: () => Promise; -}>; - -/** A run directory this module created: an optional embedded creator PID, then a UUID. */ -const RUN_NAME = /^(?:(\d+)-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** True once `pid` no longer exists; mirrors the ACP launcher's own log retention check. */ -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - // ESRCH: no such process. Any other error (for example EPERM) means it still exists. - return (error as NodeJS.ErrnoException).code !== "ESRCH"; - } -} - -/** - * True when `name` is a directory this module created for a capture run: its name matches - * {@link RUN_NAME} and it holds a manifest (`manifest.json` from {@link createProviderCapture} or - * `manifest.jsonl` from {@link openHttpTrace}). Anything else under `root` — unrelated files, - * other directories, a run whose manifest write raced with a crash — is never touched. - */ -async function isCaptureRun(root: string, name: string): Promise { - if (!RUN_NAME.test(name)) return false; - try { - if (!(await stat(join(root, name))).isDirectory()) return false; - } catch { - return false; - } - for (const manifest of ["manifest.json", "manifest.jsonl"]) { - try { - await stat(join(root, name, manifest)); - return true; - } catch { - // Try the other manifest name. - } - } - return false; -} - -/** - * Removes recognized capture run directories under `root` beyond the `keep` most recently - * modified. Only entries {@link isCaptureRun} recognizes are ever candidates; every other file or - * directory under `root` is left exactly as found. A run whose name embeds a still-alive process - * ID (see {@link openHttpTrace}) is never removed, regardless of age, so a long-running launcher's - * own active run survives a concurrent launcher's prune. A run that fails to stat is treated as - * newest and kept, so a write in progress is never pruned mid-flight. - */ -export async function pruneProviderCaptures(root: string, keep: number): Promise { - let entries: string[]; - try { - entries = await readdir(root); - } catch { - return; - } - const runs = ( - await Promise.all(entries.map(async (name) => ((await isCaptureRun(root, name)) ? name : null))) - ).filter((name): name is string => name !== null); - const withAge = await Promise.all( - runs.map(async (name) => { - const pid = Number(RUN_NAME.exec(name)?.[1]); - if (Number.isFinite(pid) && isProcessAlive(pid)) return null; - try { - return { name, mtimeMs: (await stat(join(root, name))).mtimeMs }; - } catch { - return { name, mtimeMs: Infinity }; - } - }), - ); - const stale = withAge - .filter((entry): entry is { name: string; mtimeMs: number } => entry !== null) - .sort((a, b) => b.mtimeMs - a.mtimeMs) - .slice(keep); - for (const { name } of stale) await rm(join(root, name), { recursive: true, force: true }); -} - -/** Options for {@link openHttpTrace}. */ -export type HttpTraceOptions = Readonly<{ - /** Retain the newest N run directories across process restarts; default 20. */ - keep?: number; - /** Rotate to a new run after this many distinct HTTP calls are recorded; default 2000. */ - maxCalls?: number; - /** Rotate to a new run once its manifest file would exceed this many bytes; default 64 MiB. */ - maxBytes?: number; -}>; - -/** A bounded, rotated side-car HTTP trace opened by {@link openHttpTrace}. */ -export type HttpTrace = Readonly<{ - /** Absolute directory of the run currently accepting writes; changes across rotation. */ - directory: () => string; - capture: ProviderCapture; - /** Waits for outstanding writes; does not stop the trace from accepting more. */ - close: () => Promise; -}>; - -const DEFAULT_HTTP_TRACE_MAX_CALLS = 2000; -const DEFAULT_HTTP_TRACE_MAX_BYTES = 64 * 1024 * 1024; - -/** - * Opens a bounded, rotated side-car HTTP trace for a long-running launcher process: full - * request/response bodies, credential-redacted, for every provider call bound to it. Bind the - * returned `capture` to every provider's `transport.capture` for this process's lifetime. - * - * `root` must be an absolute path (never a relative path resolved against an arbitrary current - * directory); it is also `resolve()`d. Prunes run directories from earlier process generations - * before starting (never one a live process still owns, and never anything under `root` that - * is not a recognized capture run), then rotates to a fresh run directory once the current one - * has recorded `maxCalls` calls or its manifest would exceed `maxBytes`. Each call's events are - * appended as one JSONL line to the current run's `manifest.jsonl`, never rewritten, so recording - * a call costs one append rather than a rewrite of every call recorded so far. - */ -export async function openHttpTrace( - root: string, - options: HttpTraceOptions = {}, -): Promise { - if (!isAbsolute(root)) - throw new Error(`HTTP trace directory must be an absolute path, got: ${root}`); - const base = resolve(root); - const keep = options.keep ?? 20; - const maxCalls = options.maxCalls ?? DEFAULT_HTTP_TRACE_MAX_CALLS; - const maxBytes = options.maxBytes ?? DEFAULT_HTTP_TRACE_MAX_BYTES; - await pruneProviderCaptures(base, keep); - - let directory = ""; - let manifestPath = ""; - let bytes = 0; - let calls = 0; - let seenCalls = new Set(); - - const rotate = async () => { - directory = join(base, `${process.pid}-${crypto.randomUUID()}`); - manifestPath = join(directory, "manifest.jsonl"); - await mkdir(directory, { recursive: true }); - bytes = 0; - calls = 0; - seenCalls = new Set(); - await pruneProviderCaptures(base, keep); - }; - await rotate(); - - let writes = Promise.resolve(); - const capture: ProviderCapture = (event) => { - writes = writes.then(async () => { - const isNewCall = !seenCalls.has(event.httpRequestId); - // Only rotate between calls; never split one call's own request/response across two runs. - if (isNewCall && (bytes >= maxBytes || calls >= maxCalls)) await rotate(); - if (isNewCall) { - seenCalls.add(event.httpRequestId); - calls++; - } - const line = `${JSON.stringify(redactDiagnostics({ timestamp: new Date().toISOString(), ...event }))}\n`; - await appendFile(manifestPath, line, "utf8"); - bytes += Buffer.byteLength(line); - }); - return writes; - }; - - return { directory: () => directory, capture, close: () => writes }; -} - -/** One caller-owned run. Files are written as evidence arrives, including failed/partial calls. */ -export async function createProviderCapture(root: string): Promise { - const runId = crypto.randomUUID(); - const directory = join(root, runId); - await mkdir(directory, { recursive: true }); - - const writeEvidence = async (name: string, text: string) => { - const target = join(directory, name); - const temporary = `${target}.pending`; - await Bun.write(temporary, text); - await rename(temporary, target); - }; - - const calls = new Map>(); - let writes = Promise.resolve(); - const capture: ProviderCapture = (event) => { - writes = writes.then(async () => { - const id = event.httpRequestId; - const fileId = encodeURIComponent(id); - const row = calls.get(id) ?? { httpRequestId: id, evidence: "completion_validation" }; - if (event.kind === "http_request") row.evidence = "actual_http"; - const { body, ...metadata } = event; - Object.assign(row, redactDiagnostics(metadata)); - if (event.kind === "http_response") row.transportPhase = event.phase; - if (event.kind === "completion") row.completionPhase = event.phase; - if (typeof body === "string") { - const name = `${fileId}.${event.kind === "http_request" ? "request" : "response"}.txt`; - await writeEvidence(name, body); - row[event.kind === "http_request" ? "requestFile" : "responseFile"] = name; - row[event.kind === "http_request" ? "requestBytes" : "responseBytes"] = - new TextEncoder().encode(body).byteLength; - if (event.kind === "http_request") { - try { - const parsed = JSON.parse(body); - const messages = parsed.messages ?? parsed.input ?? parsed.contents ?? []; - row.messageCount = Array.isArray(messages) ? messages.length : 0; - const texts = Array.isArray(messages) - ? messages.map((value: unknown) => JSON.stringify(value)) - : []; - row.repeatedMessageBytes = texts.reduce( - (sum: number, text: string, index: number) => - sum + (texts.indexOf(text) < index ? new TextEncoder().encode(text).byteLength : 0), - 0, - ); - } catch { - /* Exact body remains available. */ - } - } - } - calls.set(id, row); - await writeEvidence( - "manifest.json", - JSON.stringify({ runId, calls: [...calls.values()] }, null, 2), - ); - await writeEvidence( - "README.md", - [ - `# Provider traffic: ${runId}`, - "", - "Actual HTTP bodies are retained separately. Scripted completion ports are not HTTP traffic. Repeated bytes count identical messages within each request; independent requests are reported separately.", - "", - "| Call | Model | Messages | Request bytes | Response bytes | Repeated message bytes | Outcome / stage |", - "| --- | --- | --- | --- | --- | --- | --- |", - ...[...calls.values()].map( - (call) => - `| ${call.httpRequestId} | ${call.model ?? ""} / ${call.wireModel ?? call.model ?? ""} | ${call.messageCount ?? ""} | [${call.requestBytes ?? ""}](${call.requestFile ?? ""}) | [${call.responseBytes ?? ""}](${call.responseFile ?? ""}) | ${call.repeatedMessageBytes ?? ""} | ${call.outcome ?? (call.error ? "failed" : "pending")} / ${call.completionPhase ?? ""}/${call.transportPhase ?? call.phase ?? ""} |`, - ), - "", - ].join("\n"), - ); - }); - return writes; - }; - await writeEvidence("manifest.json", JSON.stringify({ runId, calls: [] })); - return { runId, directory, capture, flush: () => writes }; -} diff --git a/packages/core-agent/environment/testing/capture-process.ts b/packages/core-agent/environment/testing/capture-process.ts deleted file mode 100644 index e8fcf880..00000000 --- a/packages/core-agent/environment/testing/capture-process.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - -import { configure } from "@logtape/logtape"; - -import { withFixtureDiagnostics } from "../../logging/fixture-capture.ts"; -import { openaiChatV2 } from "../../providers/index.ts"; -import { createSession } from "../../session/index.ts"; -import { createMemoryPersistence } from "../../session/testing/memory-persistence.ts"; -import { createProviderCapture } from "../provider-capture.ts"; - -const [root, baseUrl] = process.argv.slice(2); -if (!root || !baseUrl) throw new Error("Capture process requires artifact root and local endpoint"); -await configure({ contextLocalStorage: new AsyncLocalStorage(), sinks: {}, loggers: [] }); -const run = await createProviderCapture(root); -await withFixtureDiagnostics(run.directory, { runId: run.runId }, async () => { - const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { - agent: "reviewer", - agents: new Map([["reviewer", { model: "crash-model" }]]), - steps: 2, - policy: { provider: "scripted-http", model: "crash-model", stream: true }, - }, - bindings: { - providers: new Map([ - [ - "scripted-http", - { - profile: openaiChatV2, - transport: { - baseUrl, - headers: { Authorization: "Bearer CRASH_CAPTURE_SECRET" }, - async capture(event) { - await run.capture(event); - if ( - event.kind === "http_response" && - typeof event.body === "string" && - event.body.includes("partial evidence") - ) { - process.send?.({ - directory: run.directory, - runId: run.runId, - httpRequestId: event.httpRequestId, - }); - } - }, - }, - }, - ], - ]), - }, - }); - await session.input("Retain this request if the process stops").settled; - throw new Error( - "The incomplete HTTP stream must remain active until the parent kills this process", - ); -}); diff --git a/packages/core-agent/fsm/fsm.test.ts b/packages/core-agent/fsm/fsm.test.ts deleted file mode 100644 index 07b9019a..00000000 --- a/packages/core-agent/fsm/fsm.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { Actor, defineMachine } from "./fsm.ts"; - -type State = - | { status: "idle" } - | { status: "running"; value: number } - | { status: "failed"; error: string }; -type Event = - | { type: "start" } - | { type: "add" } - | { type: "bad" } - | { type: "failed"; error: string }; -type Command = { type: "work" }; - -const decide = defineMachine({ - idle: { start: () => ({ state: { status: "running", value: 0 }, commands: [{ type: "work" }] }) }, - running: { - add: (state) => ({ state: { ...state, value: state.value + 1 }, commands: [] }), - bad: () => { - throw new Error("invalid decision"); - }, - failed: (_, event) => ({ state: { status: "failed", error: event.error }, commands: [] }), - }, - failed: {}, -}); - -test("commits a complete frozen state before dispatching commands or reentrant events", async () => { - const seen: State[] = []; - const actor = new Actor( - { status: "idle" }, - decide, - () => { - seen.push(actor.snapshot); - expect(Object.isFrozen(actor.snapshot)).toBe(true); - void actor.send({ type: "add" }); - return undefined; - }, - (_, error) => ({ type: "failed", error: String(error) }), - ); - await actor.send({ type: "start" }); - await actor.send({ type: "add" }); - expect(seen).toEqual([{ status: "running", value: 0 }]); - expect(actor.snapshot).toEqual({ status: "running", value: 2 }); -}); - -test("a rejected decision preserves state and dispatches nothing; the mailbox recovers", async () => { - let commands = 0; - const actor = new Actor( - { status: "idle" }, - decide, - () => { - commands++; - return undefined; - }, - (_, error) => ({ type: "failed", error: String(error) }), - ); - await actor.send({ type: "start" }); - const before = actor.snapshot; - await expect(actor.send({ type: "bad" })).rejects.toThrow("invalid decision"); - expect(actor.snapshot).toBe(before); - expect(commands).toBe(1); - await Promise.all([actor.send({ type: "add" }), actor.send({ type: "add" })]); - expect(actor.snapshot).toEqual({ status: "running", value: 2 }); -}); - -test("command failure is another event and does not undo the committed transition", async () => { - let observed: State | undefined; - const actor = new Actor( - { status: "idle" }, - decide, - () => { - observed = actor.snapshot; - throw new Error("offline"); - }, - (_, error) => ({ type: "failed", error: String(error) }), - ); - await actor.send({ type: "start" }); - await actor.send({ type: "add" }); - expect(observed).toEqual({ status: "running", value: 0 }); - expect(actor.snapshot).toEqual({ status: "failed", error: "Error: offline" }); - const terminal = actor.snapshot; - await actor.send({ type: "start" }); - expect(actor.snapshot).toBe(terminal); -}); diff --git a/packages/core-agent/fsm/fsm.ts b/packages/core-agent/fsm/fsm.ts deleted file mode 100644 index 10eec250..00000000 --- a/packages/core-agent/fsm/fsm.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * The entire next state and its post-commit commands form one synchronous decision. - * {@link Actor.send} installs (freezes) `state` before it runs any of `commands`, so a command - * whose adapter reports back immediately already sees the state this decision installed. - */ -export type Decision = Readonly<{ state: S; commands: readonly C[] }>; -/** A {@link Decision} that keeps `state` unchanged and issues no commands. */ -export const stay = (state: S): Decision => ({ state, commands: [] }); - -/** - * Transition table for {@link defineMachine}, keyed by state `status`, then by event `type`. - * Each handler receives the state and event narrowed to its row and column and returns the complete - * {@link Decision}. A missing entry means the event is ignored in that status. - */ -export type Rules = { - [P in S["status"]]: { - [T in E["type"]]?: ( - state: Extract, - event: Extract, - ) => Decision; - }; -}; - -/** - * Builds a pure reducer from a {@link Rules} table. - * Missing edges ignore irrelevant or late messages, including messages to terminal actors: the - * reducer then returns {@link stay} (the same state object, no commands). Handlers must not mutate - * state or start I/O; effects belong in the returned commands. - */ -export function defineMachine( - rules: Rules, -) { - return (state: S, event: E): Decision => { - // The table indexes both discriminants; the public rule type enforces each handler's inputs. - const row = rules[state.status as S["status"]] as Partial< - Record Decision> - >; - return row[event.type as E["type"]]?.(state, event) ?? stay(state); - }; -} - -/** - * Deep-freezes `value` in place and returns it. - * Domain snapshots contain data, not controllers, callbacks, maps or mutable resources. Only own - * enumerable property values are traversed, so the entries of a Map or Set stay mutable, and - * functions are not frozen. Cyclic references are handled. - */ -export function freeze(value: T, seen = new WeakSet()): T { - if (value && typeof value === "object" && !seen.has(value)) { - seen.add(value); - for (const child of Object.values(value)) freeze(child, seen); - Object.freeze(value); - } - return value; -} - -/** - * Serialized mailbox around a pure reducer: events are decided one at a time, in `send` order. - * Each decision's state is installed before its commands run. - * - * @param initial - Frozen and installed as the first snapshot. - * @param decide - Pure reducer, usually from {@link defineMachine}. - * @param execute - Dispatches one command synchronously. Asynchronous work must report back later - * through {@link Actor.send}. - * @param commandFailed - Turns a synchronous throw from `execute` into a new event for this actor. - * The event is queued after the current one; it does not roll back the installed state or the - * commands already dispatched. - */ -export class Actor { - private tail: Promise = Promise.resolve(); - private state: S; - - constructor( - initial: S, - private readonly decide: (state: S, event: E) => Decision, - private readonly execute: (command: C) => undefined, - private readonly commandFailed: (command: C, error: unknown) => E, - ) { - this.state = freeze(initial); - } - - /** - * The last installed state, deeply frozen. Events still queued in the mailbox are not reflected. - */ - get snapshot(): S { - return this.state; - } - - /** - * Queues `event` in the mailbox. Pure reduction commits once. Commands cannot run until the - * entire decision succeeds. - * - * @returns Resolves with the state this event installed, once its commands have been dispatched - * (not when their asynchronous work finishes). Any `commandFailed` event is decided later and - * is not reflected. Rejects when `decide` throws; the state is then unchanged and later events - * are still processed. - */ - send(event: E): Promise { - const result = this.tail.then(() => { - const decision = this.decide(this.state, event); - this.state = freeze(decision.state); - for (const command of decision.commands) { - try { - this.execute(command); - } catch (error) { - // This is a new domain event, not rollback of already dispatched commands. - void this.send(this.commandFailed(command, error)); - } - } - return this.state; - }); - this.tail = result.catch(() => {}); - return result; - } -} diff --git a/packages/core-agent/fsm/index.ts b/packages/core-agent/fsm/index.ts deleted file mode 100644 index a8e9ad62..00000000 --- a/packages/core-agent/fsm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./fsm.ts"; diff --git a/packages/core-agent/host/README.md b/packages/core-agent/host/README.md deleted file mode 100644 index f4be0aab..00000000 --- a/packages/core-agent/host/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# Completion and tool integration - -The host owns the lifetime of external work for both session and in-memory runtimes. Put tools, -completion adapters, and permission UI here through bindings. Do not put controllers or callbacks -in snapshots, and do not add a second execution loop in a UI or protocol adapter: two loops could -execute the same admitted tool call twice. - -Most consumers only need `defineTool` and `CompletionPort`, exported by the session API. Direct -`createHost` integration is for runtime authors who also take responsibility for outcome routing -and the result-release gate described below. - -## Implement a tool - -```ts -import { isAbsolute } from "node:path"; - -import { defineTool } from "@labkit/core-agent"; -import { z } from "zod"; - -const readDesign = defineTool({ - input: z.object({ path: z.string().refine(isAbsolute, "Use an absolute path") }), - kind: "read", - locations: ({ path }) => [{ path }], // Validated absolute display path, not authorization. - async run({ path }, signal, context) { - signal.throwIfAborted(); - return Bun.file(path).text(); - }, -}); -``` - -Input is validated before execution. Output must be JSON: use null instead of undefined and convert -dates/classes explicitly. Output validation can fail after the tool's external effect has happened; -a failure therefore does not imply rollback. Keep parsing and `locations` pure, since the host may -use them before permission is granted. Enforce filesystem or service access rules inside your tool; -display locations are not a sandbox. - -Pass the operation's AbortSignal into downstream I/O. `ToolRunContext` supplies `sessionId`, -`turnId`, `batchId`, provider `callId`, and unique operation `toolCallId`. Use it for nested request -capture or resources such as a terminal. Names and argument equality do not distinguish repeated -calls. Context is not an approval token, and a tool called directly outside the host has no context. - -## Implement a completion binding - -`CompletionPort(request, signal, blobs?, onDelta?, correlation?)` receives a validated prepared -request. Return `{ completion, continuationPayload?, usage? }`; the host validates the untrusted response -before accepting it. The public request/response types and the -[executable exchange](../session/examples/completion-binding.ts) show tool arguments and messages. -Use [provider bindings](../providers/README.md) for supported HTTP dialects. Before invoking the -completion port, the host logs its full ordered system messages as `completion.system_prompt` -at DEBUG. This shows the instructions actually supplied to that operation, including committed -shared-instruction changes; restoration alone does not emit a fictitious completion. - -Use the supplied blob resolver for attachments and continuations; it is scoped to that operation. -Do not retain it in domain state. The session arranges blob storage and loading while the host owns -cancellation. A continuation write failure prevents a successful completion outcome, so tools -cannot run from a response whose required context could not be saved. - -Optional `usage` is serializable response accounting, exported as `CompletionUsage` from the -session entry point. Keep missing counters absent and preserve native counters. The host carries -it with the admitted completion; it cannot release accounting ahead of that completion's receipt. -`completion.usage.received` identifies validation at the operation boundary. The session separately -logs `completion.usage.committed` after persistence. Neither means a cumulative bill or a count of -current context tokens. - -Optional deltas are for display. A stream still needs one complete validated response: partial text -cannot authorize tool execution or count as a successful answer. Pass correlation into transport -capture so a bad response can be identified without reconstructing the request from the journal. - -## Permission and display ports - -`requestPermission(request, signal)` is authoritative. With `permissions: "ask"`, the host validates -inputs and resolves approval for calls in order. Under `return-error-and-continue`, an invalid input -records an `invalid_input` decision without asking permission. After that decision commits, the -host reports a failed tool result containing the validation cause; the invalid call never runs. -Valid siblings still require approval. The model receives all committed results to choose its next -action. `tool.input_rejected` explains the invalid call and consequence in diagnostics. Return `allow-once`, `allow-session`, `reject-once`, or a -cancelled outcome. Each call is decided independently, in order: a refusal answers only that call -with a permission-refused result and the rest of the batch still runs; only a cancelled outcome -stops asking about the rest of the batch. A malformed response or callback failure fails closed; -cancellation or a port failure discards uncommitted grants, and late approval cannot start a tool. -`allow-session` approves the named tool for all arguments in this host's live session. The host -installs that grant when the committed permission outcome releases the batch, even when another -call in the same batch was refused. Later calls -still validate inputs and commit permission outcomes, with the original grant ID and `remembered` -source. Other tools still need approval. Only cancellation or a port failure discards uncommitted -grants. Closing the host, or applying a configuration whose permission mode or allowed tools -changed, clears remembered grants; restore does not resurrect them. `permission.granted`, -`permission.reused`, and `permission.grants_cleared` explain this at INFO. Model, thinking, and -limit changes retain approvals, and re-selecting the permission mode already in force changes -nothing. Direct host consumers call `resetPermissions` after applying a changed permission mode or -tool scope; dispatch does not infer authorization changes from a general policy revision. -An existing committed grant survives cancellation of a later turn; cancellation stops work rather -than changing the user's authorization. To revoke remembered grants in ACP, switch Tool approvals to -another mode and back. This revokes them even when both switches happen during one turn and the -mode in force never changes (`configuration.unchanged` with `revokesGrants: true`). - -`toolUpdate` and `streamUpdate` are best-effort display subscribers. Their exceptions or pending -promises cannot block execution. Use their operation IDs to update existing cards/chunks. Tool updates include the known tool name -so adapters can select display bindings without recovering it from a journal or another callback. Restore -emits no historical host notifications; render saved history from session state instead. - -| Signal | Suitable use | Authority it does not provide | -| ------------------- | ------------------------------------------ | ----------------------------------------------- | -| Pending tool card | Show intended work and resolved locations. | Permission to execute. | -| Completed tool card | Show the locally validated result. | Proof that the result was saved. | -| Stream delta | Show provisional text or thinking. | A complete answer or admitted tool call. | -| Permission response | Allow this call within its batch. | A journal receipt or approval for another call. | - -## Why a tool result has a separate release gate - -A session must save each tool result before a batch can use it. Otherwise the next model request -could depend on a result that disappears after a crash. The host reports the result to the runtime, -then waits for `releaseTool(outcome)`. The session calls that method only after the matching append -receipt. The in-memory runtime releases immediately because it makes no persistence promise. - -Direct host consumers must release each correlated result at most once. Do not release from a -notification callback or infer a commit from logging. See the -[tool-result diagram](../../../docs/session-runtime.md#save-each-tool-result-before-continuing). - -## Cancellation is a settlement rule, not rollback - -The host captures optional completion/tool deadlines when spawning work. Expiry sends cancellation -with classification `timeout` and its limit. Explicit cancellation has a different reason. Timers -are cleared on settlement/close; permission waiting has no deadline in core. - -An operation settles once. A tool that ignores cancellation may still change the outside world, but -its late result cannot complete a replacement operation or restart the loop. Batch failure cancels -unfinished siblings with the initiating failure attached, preserving which call caused the stop. -There is no implicit retry. Forks own separate hosts, so closing one does not cancel another. - -## Find the cause of a stopped operation - -`child.failed` records the originating validation/execution phase and diagnostic cause; -`child.timed_out` adds the configured limit. `tool.awaiting_release` means execution finished but -the runtime has not released its result. For a session, investigate storage when that wait persists. -`permission.waiting` instead means the entire batch awaits a user decision. These waits require -different interventions and must not be presented as a generic “tool running” state. - -Public failures preserve serializable operation identity and cause; runtime logs additionally carry -stacks. Configure the [environment sink](../logging/README.md) and join records by session/turn/child -and batch/call IDs. Inspect real success and failure paths with: - -```sh -LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun test packages/core-agent/host packages/core-agent/session/consumer-contract.test.ts -``` - -## Effects - -`ExecutionBindings.effects` is an [`EffectEmitter`](../effects/README.md) for this host's effect -events: child lifecycle (`child.started`/`child.settled`/`child.failed`/`child.timed_out`/ -`child.cancellation_requested`), `command.dispatched`, tool admission (`tool.admitted`, -`tool.locations_resolved`/`_failed`, `tool.awaiting_release`, `tool.released`, -`tool.status_changed`, `tool.input_rejected`), permission decisions (`permission.waiting`, -`permission.reused`, `permission.decided`, `permission.refused`, `permission.granted`, -`permission.grants_cleared`), and completion accounting (`completion.system_prompt`, -`completion.usage.received`). Supplying `effects` never replaces the default diagnostics -subscriber; both run. `TransportBinding.effects` on a [provider binding](../providers/README.md) -carries the matching provider-side events (`provider.http.*`, `provider.stream.*`, -`provider.completion.*`, `provider.usage.invalid`). Emission happens only at these operation -boundaries, never inside a pure decision. - -## Module map - -| Module | Contents | -| ------------------------ | --------------------------------------------------------------------------------- | -| `host.ts` | Public types and `createHost`: dispatch checks, the table call, public methods | -| `context.ts` | `HostContext`: registries, children, grants, sinks, and the `spawn`/`cancel` core | -| `commands/index.ts` | The handler table keyed by turn command type, plus `cancel` | -| `commands/model.ts` | `complete`: projects the step's prompt, then makes its LLM call, as one operation | -| `commands/permission.ts` | `request_permission`: input validation and permission grants | -| `commands/tools.ts` | `run_tools`: the tool batch actor and each tool call | -| `ports.ts` | Binding types, `defineTool`, and registry validation | -| `notifications.ts` | Best-effort delivery to display sinks | -| `testing/` | Contract suites for completion bindings and tools | diff --git a/packages/core-agent/host/commands/index.ts b/packages/core-agent/host/commands/index.ts deleted file mode 100644 index 83495485..00000000 --- a/packages/core-agent/host/commands/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { TurnCommand } from "../../agent/agent-fsm.ts"; -import type { ActorId } from "../../agent/types.ts"; -import type { HostContext } from "../context.ts"; -import type { ExecutionContext } from "../host.ts"; -import { completeModel } from "./model.ts"; -import { requestPermission } from "./permission.ts"; -import { runTools } from "./tools.ts"; - -/** - * A host command with a specific type. - */ -export type HostCommand = Extract; - -/** - * Handler function for a host command of type K. - */ -export type HostCommandHandler = ( - host: HostContext, - turnId: ActorId, - command: HostCommand, - context: ExecutionContext, -) => void; - -/** - * Table of command handlers indexed by command type. - * Each handler is responsible for spawning the appropriate child operation. - */ -const hostCommands: { readonly [K in TurnCommand["type"]]: HostCommandHandler } = { - cancel: (host, _turnId, command) => host.cancel(command.child), - complete: completeModel, - request_permission: requestPermission, - run_tools: runTools, -}; - -/** - * Runs the handler registered for `type` on a command of that type. - */ -export function runHostCommand( - host: HostContext, - turnId: ActorId, - type: K, - command: HostCommand, - context: ExecutionContext, -): void { - hostCommands[type](host, turnId, command, context); -} diff --git a/packages/core-agent/host/commands/model.ts b/packages/core-agent/host/commands/model.ts deleted file mode 100644 index 8572f09e..00000000 --- a/packages/core-agent/host/commands/model.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { z } from "zod"; - -import { admittedCompletionSchema } from "../../agent/agent-fsm.ts"; -import type { TurnCommand } from "../../agent/agent-fsm.ts"; -import { PreparedModelSchema } from "../../agent/agent.ts"; -import { CompletionSchema, type ActorId } from "../../agent/types.ts"; -import { - ContinuationSchema, - matchingContinuations, - StreamDeltaSchema, -} from "../../providers/types.ts"; -import { CompletionUsageSchema } from "../../providers/usage.ts"; -import type { HostContext } from "../context.ts"; -import type { ExecutionContext, HostStreamNotification } from "../host.ts"; -import { notify } from "../notifications.ts"; - -/** - * Projects the step's prompt and makes its LLM call as one host operation: preparation failures - * (an unreadable attachment, a projection error) and completion failures both settle as - * `model_settled`, so the turn machine never sees the prompt that produced a request, only its - * output. - */ -export function completeModel( - host: HostContext, - turnId: ActorId, - command: Extract, - context: ExecutionContext, -): void { - const prompt = context.prompt; - if (!prompt) throw new Error("Host complete requires prompt context"); - const agent = prompt.agent; - const model = context.provider?.model ?? agent.model; - const provider = context.provider?.provider; - if (host.closed) return; - const identity = { - ...(host.sessionId ? { sessionId: host.sessionId } : {}), - turnId, - completionId: command.child.id, - generation: command.turn.generation, - }; - let status = "pending"; - let stream = false; - const notifyStream = (fields: Omit) => { - if (!host.closed && stream) notify(host.streamUpdate, { ...identity, ...fields }); - }; - - host.spawn( - command.child, - { - failureContext: { - operation: { - id: command.child.id, - kind: "completion", - sessionId: host.sessionId, - turnId, - }, - }, - timeoutMs: context.completionTimeoutMs, - input: null, - parseInput: z.null().parse, - run: async (_, signal) => { - const projected = await context.projectPrompt(prompt, signal); - for (const pointer of projected.pointers) - host.emit({ - type: "prompt.media.pointer", - sessionId: host.sessionId, - turnId, - childId: command.child.id, - provider: prompt.target?.provider, - model: prompt.target?.model, - pointer, - }); - const prepared = PreparedModelSchema.parse({ - model, - ...(context.provider?.provider - ? { - provider: context.provider.provider, - thinking: context.provider.thinking, - thinkingBudgetTokens: context.provider.thinkingBudgetTokens, - stream: context.provider.stream, - maxOutputTokens: context.provider.maxOutputTokens, - successors: agent.successors ?? [...host.agents.keys()], - } - : {}), - messages: projected.messages, - tools: agent.tools.map((name) => ({ - type: "function", - function: { - name, - description: host.tools.get(name)!.description, - parameters: host.tools.get(name)!.parameters, - }, - })), - }); - await context.loadBlobs?.(prepared, signal); - const continuations = matchingContinuations( - prepared.messages, - context.continuations ?? [], - prepared.provider, - ); - const request = PreparedModelSchema.parse({ - ...prepared, - ...(continuations.length ? { continuations } : {}), - }); - // Whether the step streams is known only once its request is projected, after the - // operation already started running, so both opening statuses are published here, and - // only for a step still live: a cancelled or timed-out step must not open a stream. - signal.throwIfAborted(); - stream = !!request.stream; - notifyStream({ sessionUpdate: "completion", status: "pending" }); - notifyStream({ sessionUpdate: "completion_update", status: "in_progress" }); - const admitted = admittedCompletionSchema( - new Set( - request.successors ?? - host.agents.get(command.turn.agent)!.successors ?? - host.agents.keys(), - ), - new Set(context.allowedTools ?? host.agents.get(command.turn.agent)!.tools), - ); - const blobs = await context.loadBlobs?.(request, signal, true); - signal.throwIfAborted(); - host.emit({ - type: "completion.system_prompt", - sessionId: host.sessionId, - turnId, - childId: command.child.id, - agentId: command.turn.agent, - provider: request.provider, - model: request.model, - systemMessages: request.messages.filter( - (message): message is Extract => - message.role === "system", - ), - }); - const raw = await host.complete( - request, - signal, - blobs, - (delta) => { - const parsed = StreamDeltaSchema.safeParse(delta); - if (parsed.success && !signal.aborted && status === "in_progress") - notifyStream({ ...parsed.data, sessionUpdate: "completion_update" }); - }, - { - sessionId: host.sessionId, - turnId, - childId: command.child.id, - generation: command.turn.generation, - }, - ); - signal.throwIfAborted(); - const wrapped = raw !== null && typeof raw === "object" && "completion" in raw; - const output = wrapped - ? z - .strictObject({ - completion: z.unknown(), - continuationPayload: z.unknown().optional(), - usage: CompletionUsageSchema.optional(), - }) - .parse(raw) - : { completion: raw }; - const completion = await admitted.parseAsync(output.completion); - const continuation = - output.continuationPayload === undefined - ? undefined - : await (context.storeContinuation ?? ((entry) => ContinuationSchema.parse(entry)))( - { - provider: z.string().parse(request.provider), - owner: { turnId, generation: command.turn.generation }, - payload: output.continuationPayload, - }, - signal, - ); - signal.throwIfAborted(); - if (output.usage) - host.emit({ - type: "completion.usage.received", - sessionId: host.sessionId, - turnId, - childId: command.child.id, - model: request.model, - usage: output.usage, - }); - return { completion, continuation, usage: output.usage }; - }, - parseOutput: z.strictObject({ - completion: CompletionSchema.brand<"AdmittedCompletion">(), - continuation: ContinuationSchema.optional(), - usage: CompletionUsageSchema.optional(), - }).parseAsync, - }, - (result) => - host.post(turnId, { - type: "model_settled", - child: command.child, - result: - result.kind === "succeeded" - ? { kind: "succeeded", value: result.value.completion } - : result, - model, - ...(provider ? { provider } : {}), - ...(result.kind === "succeeded" && result.value.usage ? { usage: result.value.usage } : {}), - ...(result.kind === "succeeded" && result.value.continuation - ? { continuation: result.value.continuation } - : {}), - }), - (state) => { - const next = - state.status === "succeeded" - ? "completed" - : state.status === "failed" || state.status === "cancelled" - ? "failed" - : state.status === "running" || state.status === "validating_output" - ? "in_progress" - : "pending"; - if (next === status) return; - status = next; - notifyStream({ - sessionUpdate: "completion_update", - status: next, - ...(state.status === "failed" - ? { error: state.error.message } - : state.status === "cancelled" - ? { error: "Completion cancelled" } - : {}), - }); - }, - ); -} diff --git a/packages/core-agent/host/commands/permission.ts b/packages/core-agent/host/commands/permission.ts deleted file mode 100644 index ccb470e4..00000000 --- a/packages/core-agent/host/commands/permission.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { z } from "zod"; - -import type { TurnCommand } from "../../agent/agent-fsm.ts"; -import { PermissionDecisionsSchema, type PermissionDecisions } from "../../agent/permissions.ts"; -import { failure, ref, type ActorId, type Failure } from "../../agent/types.ts"; -import { freeze } from "../../fsm/fsm.ts"; -import { diagnosticError } from "../../logging/index.ts"; -import type { HostContext } from "../context.ts"; -import type { ExecutionContext, HostToolNotification } from "../host.ts"; -import { PermissionResponseSchema, ToolLocationSchema, type ToolLocation } from "../ports.ts"; - -/** - * Asks the permission port about each tool call of a completion, in order, and records the - * validated inputs and decisions as a grant that the matching `run_tools` batch must present. A - * refusal records the call as refused and continues to the next call; a cancelled request stops - * asking and revokes the grant. - */ -export function requestPermission( - host: HostContext, - turnId: ActorId, - command: Extract, - context: ExecutionContext, -): void { - const grant = { - batchId: command.batch.id, - approved: false, - inputs: new Map(), - invalidInputs: new Map(), - refused: new Map(), - pending: [] as HostToolNotification[], - remembered: new Map(), - }; - host.grants.set(command.child.id, grant); - host.spawn( - command.child, - { - failureContext: { - operation: { - id: command.child.id, - kind: command.child.kind, - sessionId: host.sessionId, - turnId, - }, - }, - input: null, - parseInput: z.null().parse, - run: async (_, signal) => { - if (!host.requestPermission) throw new Error("Missing permission request binding"); - const decisions: PermissionDecisions[number][] = []; - for (const call of command.completion.calls) { - let phase = "validate_input"; - try { - signal.throwIfAborted(); - const tool = host.tools.get(call.name)!; - const identity = { - ...(host.sessionId ? { sessionId: host.sessionId } : {}), - turnId, - batchId: command.batch.id, - callId: call.id, - toolCallId: ref("tool", `${command.batch.id}/${call.id}`).id, - name: call.name, - }; - const display = { - ...identity, - sessionUpdate: "tool_call" as const, - title: call.name, - name: call.name, - kind: tool.kind ?? "other", - status: "pending" as const, - rawInput: call.args, - }; - grant.pending.push(display); - host.notifyTool(display); - signal.throwIfAborted(); - const input = await tool.parseInput(call.args); - signal.throwIfAborted(); - grant.inputs.set(call.id, input); - let locations: readonly ToolLocation[] | undefined; - if (tool.locations) { - try { - locations = z - .array(ToolLocationSchema) - .parse(tool.locations(structuredClone(input))); - } catch (error) { - host.emit({ - type: "tool.locations_failed", - ...identity, - toolName: call.name, - error: diagnosticError(error), - childId: identity.toolCallId, - }); - } - } - if (locations) - host.notifyTool({ ...identity, sessionUpdate: "tool_call_update", locations }); - signal.throwIfAborted(); - const permissionStartedAt = performance.now(); - const permissionContext = { - ...identity, - childId: command.child.id, - requestId: `${command.child.id}/${call.id}`, - toolName: call.name, - locations, - }; - const rememberedGrant = - host.remembered.get(call.name) ?? grant.remembered.get(call.name); - if (rememberedGrant) { - const approval = { - scope: "live-session-tool" as const, - source: "remembered" as const, - grantId: rememberedGrant, - }; - decisions.push({ callId: call.id, decision: "allow_once", approval }); - host.emit({ - type: "permission.reused", - ...permissionContext, - ...approval, - }); - continue; - } - host.emit({ type: "permission.waiting", ...permissionContext }); - phase = "await_permission"; - const response = PermissionResponseSchema.parse( - await host.requestPermission( - freeze({ - ...(host.sessionId ? { sessionId: host.sessionId } : {}), - turnId, - requestId: `${command.child.id}/${call.id}`, - toolCall: { - toolCallId: identity.toolCallId, - title: call.name, - name: call.name, - kind: tool.kind ?? "other", - status: "pending", - rawInput: structuredClone(call.args), - ...(locations ? { locations } : {}), - }, - options: [ - { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, - { - optionId: "allow-session", - name: `Allow ${call.name} for all arguments until session closes`, - kind: "allow_always", - }, - { optionId: "reject-once", name: "Reject", kind: "reject_once" }, - ], - }), - signal, - ), - ); - signal.throwIfAborted(); - const decision = - response.outcome.outcome === "cancelled" - ? "cancelled" - : response.outcome.optionId === "allow-once" || - response.outcome.optionId === "allow-session" - ? "allow_once" - : "reject_once"; - host.emit({ - type: "permission.decided", - ...permissionContext, - decision, - durationMs: Math.round(performance.now() - permissionStartedAt), - }); - if (decision === "reject_once") { - grant.refused.set(call.id, { - message: "Permission refused by the user", - classification: "permission_refused", - phase: "permission", - details: { - requestId: permissionContext.requestId, - permissionChildId: command.child.id, - }, - }); - host.emit({ - type: "permission.refused", - ...permissionContext, - toolKind: tool.kind ?? "other", - rawInput: call.args, - durationMs: Math.round(performance.now() - permissionStartedAt), - }); - } - const approval = - response.outcome.outcome === "selected" && - response.outcome.optionId === "allow-session" - ? { - scope: "live-session-tool" as const, - source: "user" as const, - grantId: permissionContext.requestId, - } - : undefined; - if (approval) grant.remembered.set(call.name, approval.grantId); - decisions.push({ callId: call.id, decision, ...(approval ? { approval } : {}) }); - if (decision === "cancelled") break; - } catch (error) { - const invalidInput = phase === "validate_input" && !signal.aborted; - const detail = failure(error, { - classification: invalidInput ? "invalid_input" : "execution", - phase, - operation: { - id: invalidInput ? `${command.batch.id}/${call.id}` : command.child.id, - kind: invalidInput ? "tool" : "permission", - sessionId: host.sessionId, - turnId, - callId: call.id, - toolName: call.name, - }, - }); - if (!invalidInput || context.toolFailure !== "return-error-and-continue") throw detail; - grant.invalidInputs.set(call.id, detail); - decisions.push({ callId: call.id, decision: "invalid_input", error: detail }); - host.emit({ - type: "tool.input_rejected", - sessionId: host.sessionId, - turnId, - toolCallId: detail.operation?.id, - toolName: call.name, - callId: call.id, - error: detail, - }); - } - } - return decisions; - }, - parseOutput: PermissionDecisionsSchema.parse, - }, - (result) => { - if (result.kind !== "succeeded") - host.revoke( - command.child.id, - result.kind === "failed" ? result.error.message : "Tool permission request cancelled", - ); - else if (result.value.some((entry) => entry.decision === "cancelled")) - host.revoke(command.child.id, "Tool permission request cancelled"); - else grant.approved = true; - host.post(turnId, { type: "permission_settled", child: command.child, result }); - }, - ); -} diff --git a/packages/core-agent/host/commands/tools.ts b/packages/core-agent/host/commands/tools.ts deleted file mode 100644 index 9a213591..00000000 --- a/packages/core-agent/host/commands/tools.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { z } from "zod"; - -import type { TurnCommand } from "../../agent/agent-fsm.ts"; -import { partsText, type ContentPart } from "../../agent/content.ts"; -import { - toolBatchMachine, - type BatchCommand, - type BatchEvent, - type BatchState, - type ToolRunResult, -} from "../../agent/tool-batch.ts"; -import { failure, type ActorId } from "../../agent/types.ts"; -import { Actor } from "../../fsm/fsm.ts"; -import { diagnosticError } from "../../logging/index.ts"; -import type { HostContext } from "../context.ts"; -import type { ExecutionContext, HostToolOutcome } from "../host.ts"; -import { ToolLocationSchema, ToolOutputSchema } from "../ports.ts"; - -/** Largest text-media tool part inlined as text (the same 256 KiB as MCP and read_file results). */ -const MAX_INLINE_TEXT = 256 * 1024; - -/** - * Runs a completion's tool calls as a tool batch actor. Installs the remembered grants of the - * approving permission request, then spawns each call as the batch asks; each raw outcome goes to - * `host.reportTool` and counts toward the batch only once released. - * - * @throws When `command.permission` names a request that did not approve this batch. - */ -export function runTools( - host: HostContext, - turnId: ActorId, - command: Extract, - context: ExecutionContext, -): void { - const grant = command.permission ? host.grants.get(command.permission.id) : undefined; - if ( - command.permission && - (!grant?.approved || - grant.batchId !== command.child.id || - command.completion.calls.some( - (call) => - !grant.inputs.has(call.id) && - !grant.invalidInputs.has(call.id) && - !grant.refused.has(call.id), - )) - ) - throw new Error("Missing tool permission grant"); - for (const [toolName, grantId] of grant?.remembered ?? []) { - host.remembered.set(toolName, grantId); - host.emit({ - type: "permission.granted", - sessionId: host.sessionId, - turnId, - childId: command.child.id, - toolName, - grantId, - policyVersion: context.policyVersion, - }); - } - if (command.permission) host.grants.delete(command.permission.id); - let batch: Actor; - const runBatchCommand = (batchCommand: BatchCommand): undefined => { - switch (batchCommand.type) { - case "spawn_tool": { - const tool = host.tools.get(batchCommand.call.name)!; - const identity = { - ...(host.sessionId ? { sessionId: host.sessionId } : {}), - turnId, - batchId: command.child.id, - callId: batchCommand.call.id, - toolCallId: batchCommand.child.id, - name: batchCommand.call.name, - }; - const refused = grant?.refused.get(batchCommand.call.id); - host.emit({ - type: "tool.admitted", - ...identity, - toolName: batchCommand.call.name, - kind: tool.kind ?? "other", - permission: refused - ? "refused" - : grant?.invalidInputs.has(batchCommand.call.id) - ? "not_requested_invalid_input" - : grant - ? "approved" - : "not_required", - }); - if (refused) { - host.emit({ - type: "tool.refused", - ...identity, - toolName: batchCommand.call.name, - ...(command.permission ? { permissionChildId: command.permission.id } : {}), - }); - if (!host.closed) - host.notifyTool({ - ...identity, - sessionUpdate: "tool_call_update", - status: "failed", - rawOutput: { refused: true, reason: refused.message }, - }); - const outcome: HostToolOutcome = { - turnId, - batchId: command.child.id, - callId: batchCommand.call.id, - result: { - kind: "failed", - error: failure(refused, { - operation: { - id: batchCommand.child.id, - kind: "tool", - sessionId: host.sessionId, - turnId, - toolName: batchCommand.call.name, - callId: batchCommand.call.id, - }, - }), - }, - }; - host.pendingTools.set(`${outcome.batchId}/${outcome.callId}`, { - outcome, - batch, - toolFailure: context.toolFailure, - }); - void Promise.resolve().then(() => { - if (!host.closed) host.reportTool(outcome); - }); - break; - } - if (!grant) - host.notifyTool({ - ...identity, - sessionUpdate: "tool_call", - title: batchCommand.call.name, - name: batchCommand.call.name, - kind: tool.kind ?? "other", - status: "pending", - rawInput: batchCommand.call.args, - }); - if (host.closed) break; - let status = "pending"; - - host.spawn( - batchCommand.child, - { - input: batchCommand.call.args, - timeoutMs: context.toolTimeoutMs, - failureContext: { - operation: { - id: batchCommand.child.id, - kind: "tool", - sessionId: host.sessionId, - turnId, - toolName: batchCommand.call.name, - callId: batchCommand.call.id, - }, - }, - parseInput: async (raw) => { - if (grant) { - const invalid = grant.invalidInputs.get(batchCommand.call.id); - if (invalid) throw invalid; - return grant.inputs.get(batchCommand.call.id); - } - const input = await tool.parseInput(raw); - if (!host.closed && status === "pending" && tool.locations) { - try { - const locations = z - .array(ToolLocationSchema) - .parse(tool.locations(structuredClone(input))); - host.emit({ - type: "tool.locations_resolved", - ...identity, - toolName: batchCommand.call.name, - locations, - }); - host.notifyTool({ - ...identity, - sessionUpdate: "tool_call_update", - locations, - }); - } catch (error) { - host.emit({ - type: "tool.locations_failed", - sessionId: host.sessionId, - childId: batchCommand.child.id, - toolName: batchCommand.call.name, - error: diagnosticError(error), - }); - } - } - return input; - }, - run: (input, signal) => tool.run(input, signal, Object.freeze(identity)), - parseOutput: async (value): Promise => { - const rich = ToolOutputSchema.safeParse(value); - if (rich.success) { - const storeBlob = host.storeBlob; - const parts: ContentPart[] = await Promise.all( - rich.data.parts.map(async (part) => { - if (part.type === "text") return part; - // Text media is inlined as text on its own line: every encoder sends tool text. - if (part.media.startsWith("text/")) { - if (part.bytes.byteLength > MAX_INLINE_TEXT) - throw new Error( - `${batchCommand.call.name} returned ${part.bytes.byteLength} bytes of ${part.media}, over the 256 KiB tool result limit; narrow the request`, - ); - return { - type: "text" as const, - text: `\n${new TextDecoder("utf-8", { fatal: true }).decode(part.bytes)}`, - }; - } - if (!storeBlob) - throw new Error("Tool returned blob parts but no blob store is configured"); - return { - type: "blob" as const, - ref: await storeBlob(part.bytes, { - media: part.media, - ...(part.name ? { name: part.name } : {}), - }), - }; - }), - ); - return { text: partsText(parts), ...(parts.length ? { parts } : {}) }; - } - const parsed = z.json().safeParse(value); - if (!parsed.success) - throw new Error( - "Tool output must be a JSON value: null, boolean, finite number, string, array, or plain object", - ); - return { - text: typeof parsed.data === "string" ? parsed.data : JSON.stringify(parsed.data), - }; - }, - }, - (result) => { - const outcome: HostToolOutcome = { - turnId, - batchId: command.child.id, - callId: batchCommand.call.id, - result, - }; - host.pendingTools.set(`${outcome.batchId}/${outcome.callId}`, { - outcome, - batch, - toolFailure: context.toolFailure, - }); - host.emit({ - type: "tool.awaiting_release", - ...identity, - toolName: batchCommand.call.name, - outcome: result.kind, - }); - host.reportTool(outcome); - }, - (state) => { - const next = - state.status === "succeeded" - ? "completed" - : state.status === "failed" || state.status === "cancelled" - ? "failed" - : state.status === "running" || state.status === "validating_output" - ? "in_progress" - : "pending"; - if (next === status) return; - host.emit({ - type: "tool.status_changed", - ...identity, - toolName: batchCommand.call.name, - previousStatus: status, - status: next, - rawStatus: state.status, - ...(state.status === "failed" ? { error: diagnosticError(state.error) } : {}), - }); - status = next; - host.notifyTool({ - ...identity, - sessionUpdate: "tool_call_update", - status: next, - ...(state.status === "succeeded" - ? { - rawOutput: state.value.text, - ...(state.value.parts ? { parts: state.value.parts } : {}), - } - : state.status === "failed" - ? { rawOutput: { error: state.error.message } } - : state.status === "cancelled" - ? { rawOutput: { error: "Tool cancelled" } } - : {}), - }); - }, - ); - break; - } - case "cancel_tool": - host.cancel(batchCommand.child, batchCommand.reason); - break; - case "notify": - host.children.delete(command.child.id); - for (const [key, pending] of host.pendingTools) - if (pending.outcome.batchId === command.child.id) host.pendingTools.delete(key); - host.post(turnId, { - type: "batch_settled", - child: command.child, - outcome: batchCommand.outcome, - }); - break; - } - return undefined; - }; - batch = new Actor( - { status: "ready", calls: command.completion.calls }, - toolBatchMachine(command.child), - runBatchCommand, - (_, error) => ({ type: "failed", error: failure(error) }), - ); - host.children.set(command.child.id, { - ref: command.child, - actor: { - get snapshot() { - return batch.snapshot; - }, - cancel: () => batch.send({ type: "cancel" }), - }, - }); - void batch.send({ type: "start" }); -} diff --git a/packages/core-agent/host/context.ts b/packages/core-agent/host/context.ts deleted file mode 100644 index 337398ac..00000000 --- a/packages/core-agent/host/context.ts +++ /dev/null @@ -1,288 +0,0 @@ -import type { TurnEvent } from "../agent/agent-fsm.ts"; -import { createOperationActor } from "../agent/operation-actor.ts"; -import type { Operation, OperationState } from "../agent/operation-actor.ts"; -import type { BatchCommand, BatchEvent, BatchState } from "../agent/tool-batch.ts"; -import type { ActorId, AgentId, ChildRef, Failure, Result } from "../agent/types.ts"; -import { diagnosticsSubscriber, fanoutEffects, type EffectEmitter } from "../effects/index.ts"; -import type { Actor } from "../fsm/fsm.ts"; -import { diagnosticError } from "../logging/index.ts"; -import type { Policy } from "../policy/policy.ts"; -import type { - HostToolNotification, - HostToolOutcome, - StreamUpdateSink, - ToolUpdateSink, -} from "./host.ts"; -import { notify } from "./notifications.ts"; -import { - copyRegistries, - type AgentDefinition, - type CompletionPort, - type ExecutionBindings, - type PermissionPort, - type Tool, -} from "./ports.ts"; - -/** - * The snapshot and cancel capability of a running child operation. - */ -export type Child = { - readonly snapshot: OperationState | BatchState; - cancel(reason?: Failure): Promise; -}; - -/** - * A child operation and its lifecycle reference. - */ -export type HostChild = { - readonly ref: ChildRef; - readonly actor: Child; -}; - -/** - * A pending tool release waiting for releaseTool to be called on it. - */ -export type PendingToolRelease = { - outcome: HostToolOutcome; - toolFailure?: Policy["toolFailure"]; - batch: Actor; -}; - -/** - * A permission grant for one batch, tracking inputs, invalidInputs, refused calls, and pending - * notifications. - */ -export type PermissionGrant = { - batchId: ActorId; - approved: boolean; - inputs: Map; - invalidInputs: Map; - refused: Map; - pending: HostToolNotification[]; - remembered: Map; -}; - -/** - * The shared execution context holding closure state for a host instance. - */ -export type HostContext = { - closed: boolean; - readonly sessionId: string | undefined; - readonly complete: CompletionPort; - readonly requestPermission: PermissionPort | undefined; - readonly storeBlob: ExecutionBindings["storeBlob"]; - readonly agents: Map; - readonly tools: Map; - readonly children: Map; - readonly pendingTools: Map; - readonly grants: Map; - readonly remembered: Map; - readonly streamUpdate: StreamUpdateSink | undefined; - readonly reportTool: (outcome: HostToolOutcome) => void; - readonly post: (turnId: ActorId, event: TurnEvent) => void; - readonly notifyTool: (notification: HostToolNotification) => void; - readonly revoke: (id: ActorId, reason: string) => void; - readonly spawn: ( - child: ChildRef, - operation: Operation, - settled: (result: Result) => void, - observe?: (state: OperationState) => unknown, - ) => void; - readonly cancel: (child: ChildRef, reason?: Failure) => void; - /** Emits this host's effect events; see {@link EffectEmitter}. */ - readonly emit: EffectEmitter; -}; - -/** - * Creates the execution context for a host instance, returning an object with all closure state. - */ -export function createHostContext( - bindings: ExecutionBindings & { sessionId?: string }, - sinks: { - turn: (turnId: ActorId, event: TurnEvent) => void; - tool: (outcome: HostToolOutcome) => void; - toolUpdate?: ToolUpdateSink; - streamUpdate?: StreamUpdateSink; - }, -): HostContext { - const { agents, tools } = copyRegistries(bindings); - const children = new Map(); - const pendingTools = new Map< - string, - { - outcome: HostToolOutcome; - toolFailure?: Policy["toolFailure"]; - batch: Actor; - } - >(); - const requestPermission = bindings.requestPermission; - const emit = fanoutEffects(diagnosticsSubscriber(), bindings.effects); - const remembered = new Map(); - - const grants = new Map< - ActorId, - { - batchId: ActorId; - approved: boolean; - inputs: Map; - invalidInputs: Map; - refused: Map; - pending: HostToolNotification[]; - remembered: Map; - } - >(); - - const ctx: HostContext = { - closed: false, - sessionId: bindings.sessionId, - complete: bindings.complete, - emit, - requestPermission, - storeBlob: bindings.storeBlob, - agents, - tools, - children, - pendingTools, - grants, - remembered, - streamUpdate: sinks.streamUpdate, - reportTool: (outcome: HostToolOutcome) => { - sinks.tool(outcome); - }, - post: (turnId: ActorId, event: TurnEvent) => { - if (!ctx.closed) sinks.turn(turnId, event); - }, - notifyTool: (notification: HostToolNotification) => { - if (ctx.closed || !toolUpdate) return; - notify(toolUpdate, notification); - }, - revoke: (id: ActorId, reason: string) => { - const grant = grants.get(id); - grants.delete(id); - for (const { sessionId, turnId, batchId, callId, toolCallId } of grant?.pending ?? []) - ctx.notifyTool({ - ...(sessionId ? { sessionId } : {}), - turnId, - batchId, - callId, - toolCallId, - sessionUpdate: "tool_call_update", - status: "failed", - rawOutput: { error: reason }, - }); - }, - spawn: ( - child: ChildRef, - operation: Operation, - settled: (result: Result) => void, - observe?: (state: OperationState) => unknown, - ) => { - const startedAt = performance.now(); - let timer: ReturnType | undefined; - const attempt = async ( - phase: string, - run: () => T | Promise, - signal?: AbortSignal, - ): Promise => { - try { - return await run(); - } catch (error) { - emit( - signal?.aborted - ? { - type: "child.cancelled", - sessionId: bindings.sessionId, - childId: child.id, - operation: child.kind, - phase, - error: diagnosticError(error), - } - : { - type: "child.failed", - sessionId: bindings.sessionId, - childId: child.id, - operation: child.kind, - phase, - error: diagnosticError(error), - }, - ); - throw error; - } - }; - const actor = createOperationActor( - child, - { - ...operation, - input: operation.input, - parseInput: (input) => attempt("validate_input", () => operation.parseInput(input)), - run: (input, signal) => attempt("run", () => operation.run(input, signal), signal), - parseOutput: (output) => attempt("validate_output", () => operation.parseOutput(output)), - }, - (result) => { - if (timer !== undefined) clearTimeout(timer); - children.delete(child.id); - emit({ - type: "child.settled", - sessionId: bindings.sessionId, - childId: child.id, - operation: child.kind, - outcome: result.kind, - durationMs: Math.round(performance.now() - startedAt), - ...(result.kind === "failed" ? { error: diagnosticError(result.error) } : {}), - }); - if (!ctx.closed) settled(result); - }, - observe, - ); - emit({ - type: "child.started", - sessionId: bindings.sessionId, - childId: child.id, - operation: child.kind, - }); - children.set(child.id, { ref: child, actor }); - if (operation.timeoutMs !== undefined) { - const timeoutMs = operation.timeoutMs; - timer = setTimeout(() => { - const reason: Failure = { - message: `${child.kind} operation ${child.id} exceeded its ${timeoutMs} ms deadline`, - classification: "timeout", - timeoutMs, - phase: actor.snapshot.status, - operation: { - id: child.id, - kind: child.kind, - sessionId: bindings.sessionId, - ...operation.failureContext?.operation, - }, - }; - emit({ - type: "child.timed_out", - reason, - sessionId: bindings.sessionId, - childId: child.id, - }); - void actor.cancel(reason); - }, timeoutMs); - } - void actor.start(); - }, - cancel: (child: ChildRef, reason?: Failure) => { - ctx.revoke(child.id, reason?.message ?? "Tool permission request cancelled"); - emit({ - type: "child.cancellation_requested", - sessionId: bindings.sessionId, - childId: child.id, - operation: child.kind, - reason, - }); - void children - .get(child.id) - ?.actor.cancel(reason ? { ...reason, classification: "cancelled" } : undefined); - }, - }; - - const toolUpdate = sinks.toolUpdate; - - return ctx; -} diff --git a/packages/core-agent/host/host.test.ts b/packages/core-agent/host/host.test.ts deleted file mode 100644 index 63917273..00000000 --- a/packages/core-agent/host/host.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { until } from "../agent/test-support.ts"; -import { - ActorIdSchema, - AgentIdSchema, - CompletionSchema, - ref, - StepsSchema, -} from "../agent/types.ts"; -import { createHost, type ExecutionContext, type HostToolOutcome } from "./host.ts"; -import { completionTransport, defineTool } from "./ports.ts"; -import { completionContract } from "./testing/completion-contract.ts"; -import { toolContract } from "./testing/tool-contract.ts"; - -completionContract("completion port through operation host", (source) => source); -toolContract("defineTool through operation host", (run) => - defineTool({ input: z.object({ text: z.string() }), run }), -); - -test("host reports tools but cannot advance a batch before explicit release", async () => { - const outcomes: HostToolOutcome[] = []; - const events: unknown[] = []; - const host = createHost( - { - agents: new Map([["a", { model: "m", tools: ["echo"] }]]), - tools: new Map([ - ["echo", defineTool({ input: z.object({ text: z.string() }), run: ({ text }) => text })], - ]), - complete: () => ({ kind: "answer", text: "unused" }), - }, - { - turn: (_, event) => { - events.push(event); - }, - tool: (outcome) => { - outcomes.push(outcome); - }, - }, - ); - const completion = CompletionSchema.parse({ - kind: "tools", - text: "work", - calls: [{ id: "c", name: "echo", args: { text: "ok" } }], - }); - if (completion.kind !== "tools") throw new Error("Expected tools"); - host.dispatch( - { - type: "turn", - turnId: ActorIdSchema.parse("turn"), - command: { type: "run_tools", child: ref("batch", "batch"), completion }, - }, - { projectPrompt: () => ({ messages: [], pointers: [] }) }, - ); - await until(() => outcomes.length === 1); - expect(events).toHaveLength(0); - host.releaseTool(outcomes[0]!); - host.releaseTool(outcomes[0]!); - await until(() => events.length === 1); - expect(events[0]).toMatchObject({ type: "batch_settled", outcome: { kind: "succeeded" } }); - host.close(); -}); -test("transport binding supplies credentials outside the serializable request", async () => { - let sent: any; - const complete = completionTransport({ - baseUrl: "https://provider.invalid", - apiKey: "secret", - fetch: (async (_url, init) => { - sent = init; - return Response.json({ choices: [{ message: { content: "answer" } }] }); - }) as typeof fetch, - }); - const request = PreparedModelSchema.parse({ - model: "m", - messages: [{ role: "user", content: "hello" }], - }); - await complete(request, new AbortController().signal); - expect(sent.headers.Authorization).toBe("Bearer secret"); - expect(JSON.stringify(request)).not.toContain("secret"); -}); - -test("a step command rejects missing prompt context before spawning operations", () => { - const host = createHost( - { - agents: new Map([["a", { model: "m" }]]), - complete: () => { - throw new Error("Must not execute"); - }, - }, - { - turn: () => { - throw new Error("Must not report a child"); - }, - tool: () => { - throw new Error("Must not execute tools"); - }, - }, - ); - const turn = { - id: ActorIdSchema.parse("turn"), - agent: AgentIdSchema.parse("a"), - generation: 1, - steps: StepsSchema.parse(1), - messages: [], - view: { kind: "history" as const }, - }; - expect(() => - host.dispatch( - { - type: "turn", - turnId: turn.id, - command: { type: "complete", child: ref("completion", "complete"), turn }, - }, - { projectPrompt: () => ({ messages: [], pointers: [] }) }, - ), - ).toThrow("complete requires prompt context"); - expect(host.snapshot).toHaveLength(0); - host.close(); -}); - -for (const cancelled of [false, true]) - test(`a streaming step ${cancelled ? "cancelled during prompt projection publishes no stream status" : "publishes paired stream statuses"}`, async () => { - const statuses: unknown[] = []; - const events: { type: string; result?: { kind: string } }[] = []; - let completions = 0; - const host = createHost( - { - agents: new Map([["a", { model: "m", tools: [], successors: [] }]]), - complete: () => { - completions++; - return { kind: "answer", text: "done" }; - }, - }, - { - turn: (_, event) => { - events.push(event); - }, - tool: () => { - throw new Error("Must not execute tools"); - }, - streamUpdate: (update) => { - if ("status" in update && update.status) statuses.push(update.status); - }, - }, - ); - let release: (messages: readonly unknown[]) => void = () => {}; - const projected = new Promise((resolve) => { - release = resolve; - }); - const turn = { - id: ActorIdSchema.parse("turn"), - agent: AgentIdSchema.parse("a"), - generation: 1, - steps: StepsSchema.parse(1), - messages: [{ role: "user" as const, text: "hi" }], - view: { kind: "history" as const }, - }; - const child = ref("completion", "turn/1"); - // Called right after projection resolves, so it shows the step's run resumed past it. - let loads = 0; - let projecting = false; - const context: ExecutionContext = { - prompt: { log: [], turn, agent: { model: "m", tools: [] } }, - provider: { provider: "p", stream: true }, - projectPrompt: () => { - projecting = true; - return projected.then((messages) => ({ messages, pointers: [] })); - }, - loadBlobs: async () => { - loads++; - return () => { - throw new Error("No blobs in this step"); - }; - }, - }; - host.dispatch( - { type: "turn", turnId: turn.id, command: { type: "complete", child, turn } }, - context, - ); - await until(() => projecting); - if (cancelled) - host.dispatch({ type: "turn", turnId: turn.id, command: { type: "cancel", child } }, context); - release([{ role: "user", content: "hi" }]); - await until(() => events.length === 1 && loads > 0); - expect(events[0]).toMatchObject({ - type: "model_settled", - result: { kind: cancelled ? "cancelled" : "succeeded" }, - }); - expect(completions).toBe(cancelled ? 0 : 1); - expect(statuses).toEqual(cancelled ? [] : ["pending", "in_progress", "completed"]); - host.close(); - }); diff --git a/packages/core-agent/host/host.ts b/packages/core-agent/host/host.ts deleted file mode 100644 index 2e28a35f..00000000 --- a/packages/core-agent/host/host.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { ConversationCommand } from "../agent/agent-conversation.ts"; -import type { TurnEvent } from "../agent/agent-fsm.ts"; -import type { PreparedModel } from "../agent/agent.ts"; -import type { BlobResolver, ContentPart } from "../agent/content.ts"; -import type { ProjectedPrompt, PromptInput } from "../agent/prompt.ts"; -import type { ToolRunResult } from "../agent/tool-batch.ts"; -import type { ActorId, Result, ToolCall } from "../agent/types.ts"; -import { freeze } from "../fsm/fsm.ts"; -import { effectiveToolResult, type Policy } from "../policy/policy.ts"; -import type { Continuation, StreamDelta } from "../providers/types.ts"; -import { runHostCommand } from "./commands/index.ts"; -import { createHostContext } from "./context.ts"; -import { - copyRegistries, - type ExecutionBindings, - type ToolKind, - type ToolLocation, -} from "./ports.ts"; - -/** - * The raw result of one tool call, reported through `createHost`'s `tool` sink before its tool - * batch may use it. The batch counts it only after the runtime passes this same object to - * `releaseTool`; a session does so after the result's append receipt commits. - */ -export type HostToolOutcome = Readonly<{ - turnId: ActorId; - batchId: ActorId; - /** The provider's call ID, unique only within its tool batch. */ - callId: ToolCall["id"]; - /** Raw result, before the configuration's `toolFailure` handling (applied on release). */ - result: Result; -}>; -/** - * A display update about one tool call, for UI cards. Non-authoritative display data. toolCallId is - * the child ID, not the provider's batch-local call ID: the tool operation's ID, - * `${batchId}/${callId}`. - * - * - `tool_call` announces the call once: while its permission is being asked, or when its tool - * batch starts if no approval was asked. - * - `tool_call_update` reports display locations, a status change, or the output. `failed` covers - * execution failure, cancellation and a refused or cancelled permission request. - */ -export type HostToolNotification = Readonly< - { - sessionId?: string; - turnId: ActorId; - batchId: ActorId; - callId: ToolCall["id"]; - toolCallId: ActorId; - } & ( - | { - sessionUpdate: "tool_call"; - title: string; - name: string; - kind: ToolKind; - status: "pending"; - rawInput: unknown; - } - | { - sessionUpdate: "tool_call_update"; - name?: string; - status?: "pending" | "in_progress" | "completed" | "failed"; - locations?: readonly ToolLocation[]; - /** - * On `completed`, the tool's output text; on `failed`, `{ error }` with the failure - * message. - */ - rawOutput?: unknown; - /** On `completed`, the result's blob parts (D4: for example a stored MCP image), if any. */ - parts?: readonly ContentPart[]; - } - ) ->; - -/** - * Observer for {@link HostToolNotification}s. Best-effort: exceptions and pending promises are - * ignored and cannot block execution. Not called after the host closes. - */ -export type ToolUpdateSink = (notification: HostToolNotification) => unknown; - -/** - * A display update about one step's completion stream, sent only when the prepared request enables - * `stream`. `completion` opens the stream with status `pending`. `completion_update` carries a - * status change, or a text/thinking delta while the status is `in_progress`. Partial output is - * display only; the step's answer is the validated response in the turn's `model_settled` event. - */ -export type HostStreamNotification = Readonly< - { - sessionId?: string; - turnId: ActorId; - /** ID of the completion operation for this step. */ - completionId: ActorId; - /** The turn's operation counter when this step started; not a step number. */ - generation: number; - sessionUpdate: "completion" | "completion_update"; - /** - * `completed` after the response validates; `failed` on failure or cancellation (including - * barge-in), with `error`. - */ - status?: "pending" | "in_progress" | "completed" | "failed"; - error?: string; - } & StreamDelta ->; - -/** - * Observer for {@link HostStreamNotification}s. Best-effort: exceptions and pending promises are - * ignored and cannot block execution. Not called after the host closes. - */ -export type StreamUpdateSink = (notification: HostStreamNotification) => unknown; - -/** - * Per-dispatch settings a runtime passes with each turn command: the configuration the turn runs - * with, plus prompt projection and storage callbacks. The host freezes a copy at dispatch, so - * later changes do not affect operations already dispatched. - */ -export type ExecutionContext = Readonly<{ - /** Configuration revision; only labels permission logs. */ - policyVersion?: number; - /** - * Completion deadline in ms, measured from spawn. Omitted means none. Expiry cancels with - * classification `timeout`. - */ - completionTimeoutMs?: number; - /** - * Per-tool-call deadline in ms, measured from spawn. Omitted means none. Expiry cancels with - * classification `timeout`. - */ - toolTimeoutMs?: number; - /** The turn's prompt input. Required for `complete`. */ - prompt?: PromptInput; - /** - * Loads the blobs a request references. Preparation calls it to check that attachments load. The - * completion calls it with `includeContinuations` and passes the resolver to the completion port. - * Omitted: no resolver is passed. - */ - loadBlobs?: ( - request: PreparedModel, - signal: AbortSignal, - includeContinuations?: boolean, - ) => Promise; - /** - * Stores a provider continuation payload. The completion awaits it before it can succeed, and a - * rejection fails the step. Omitted: the payload is kept inline in the returned continuation. - */ - storeContinuation?: ( - entry: { provider: string; owner: Continuation["owner"]; payload: unknown }, - signal: AbortSignal, - ) => Promise; - /** - * Stored continuations. Those owned by an assistant message in the prepared request, for the same - * provider, are attached to it. - */ - continuations?: readonly Continuation[]; - /** Tool names a completion may call. Omitted: the agent's tools. */ - allowedTools?: readonly string[]; - /** - * Tool-failure handling. Under `return-error-and-continue`, invalid input is recorded instead of - * failing the permission request, and a failed tool result is converted on release. - */ - toolFailure?: Policy["toolFailure"]; - /** - * Model settings from the configuration. `model` overrides the agent's model. The other fields, - * and the agent's successors, are applied only when `provider` is set. - */ - provider?: Pick< - Policy, - "provider" | "model" | "thinking" | "thinkingBudgetTokens" | "stream" | "maxOutputTokens" - >; - /** - * Prompt projection: builds the step's messages from the turn's prompt input at the step - * boundary, run inline by `complete` before its LLM call. The messages are validated as chat - * messages; each pointer is reported as `prompt.media.pointer`. A handoff's own prompt input names - * the predecessor via `turn.view`; the projection renders it into the handoff packet plus the - * messages added since. - */ - projectPrompt: ( - input: PromptInput, - signal: AbortSignal, - ) => ProjectedPrompt | Promise; -}>; - -/** - * Creates the host that runs a turn's child operations (child: an operation the turn spawned, not a - * child session): completions (prompt projection and the LLM call, as one operation), permission - * requests and tool batches. An execution adapter, not another state machine or persistence gate. - * Outcomes return through `sinks`; none is delivered after `close`. - * - * @param bindings - Registries and ports, validated and copied by {@link copyRegistries}. - * `sessionId` labels notifications and logs. - * @throws The errors of {@link copyRegistries}. - */ -export function createHost( - bindings: ExecutionBindings & { sessionId?: string }, - sinks: { - /** - * Receives each child operation's outcome as a {@link TurnEvent} for the named turn: - * `model_settled`, `permission_settled` or `batch_settled`. - */ - turn: (turnId: ActorId, event: TurnEvent) => void; - /** - * Receives each tool call's raw outcome. Its tool batch waits until the runtime passes the same - * object to `releaseTool`. - */ - tool: (outcome: HostToolOutcome) => void; - toolUpdate?: ToolUpdateSink; - streamUpdate?: StreamUpdateSink; - }, -) { - const ctx = createHostContext(bindings, sinks); - const dispatch = ( - effect: Extract, - context: ExecutionContext, - ): undefined => { - if (ctx.closed) throw new Error("Host closed"); - context = freeze({ - ...context, - prompt: context.prompt ? structuredClone(context.prompt) : undefined, - allowedTools: context.allowedTools?.slice(), - }); - const { turnId, command } = effect; - ctx.emit({ - type: "command.dispatched", - sessionId: bindings.sessionId, - turnId, - childId: command.child.id, - operation: command.type, - provider: context.provider?.provider, - model: context.provider?.model, - thinking: context.provider?.thinking, - thinkingBudgetTokens: context.provider?.thinkingBudgetTokens, - stream: context.provider?.stream, - maxOutputTokens: context.provider?.maxOutputTokens, - ...("turn" in command - ? { generation: command.turn.generation, stepsRemaining: command.turn.steps } - : {}), - }); - runHostCommand(ctx, turnId, command.type, command, context); - return undefined; - }; - - return { - /** - * Starts the host side of one turn command: spawns the child operation it names, or, for - * `cancel`, cancels an active one. Returns once the operation is spawned; its outcome arrives - * later through `sinks.turn`. - * - * @throws When the host is closed; when `complete` has no `context.prompt`; when `run_tools` - * names a permission request that did not approve this batch. - */ - dispatch, - /** - * Forgets all remembered `allow-session` grants, so later calls of those tools ask again. Call - * it after applying a changed permission mode or tool scope; dispatch never infers this from a - * configuration change. Grants from a settled permission request whose tool batch has not - * started yet are installed when it starts. `reason` and `correlation` (the applying append, - * or the selection that changed and restored the mode) only label the log. - */ - resetPermissions( - reason: string, - correlation: { policyVersion: number; appendId?: string; selectionId?: string }, - ) { - ctx.emit({ - type: "permission.grants_cleared", - sessionId: bindings.sessionId, - reason, - ...correlation, - toolNames: [...ctx.remembered.keys()], - }); - ctx.remembered.clear(); - }, - /** - * Lets one tool call's outcome count toward its tool batch. Pass the exact object received by - * `sinks.tool`; other objects, repeats, outcomes of a settled batch, and calls after `close` - * are ignored. The configured `toolFailure` handling is applied here: under - * `return-error-and-continue`, a failure other than a timeout becomes a successful result - * carrying the error. - */ - releaseTool(outcome: HostToolOutcome) { - const key = `${outcome.batchId}/${outcome.callId}`; - const pending = ctx.pendingTools.get(key); - if (!pending || pending.outcome !== outcome) return; - ctx.pendingTools.delete(key); - ctx.emit({ - type: "tool.released", - sessionId: bindings.sessionId, - turnId: outcome.turnId, - batchId: outcome.batchId, - callId: outcome.callId, - }); - void pending.batch.send({ - type: "tool_settled", - callId: outcome.callId, - result: effectiveToolResult( - outcome.result, - pending.toolFailure ? { toolFailure: pending.toolFailure } : undefined, - ), - }); - }, - /** - * Cancels every active child operation and drops pending tool releases, permission grants and - * remembered `allow-session` grants. Afterwards no sink is called and `dispatch` throws. Does - * not wait for operations to stop. - */ - close() { - ctx.emit({ - type: "host.closed", - sessionId: bindings.sessionId, - activeChildren: ctx.children.size, - pendingToolReceipts: ctx.pendingTools.size, - pendingGrants: ctx.grants.size, - }); - ctx.closed = true; - for (const { actor } of ctx.children.values()) void actor.cancel(); - ctx.pendingTools.clear(); - ctx.grants.clear(); - ctx.remembered.clear(); - }, - /** - * Frozen list of active child operations with their current states, for diagnostics and runtime - * snapshots. Includes each running tool call as well as its tool batch. An operation leaves the - * list when it settles; a tool batch leaves when it reports `batch_settled`. - */ - get snapshot() { - return freeze( - [...ctx.children.values()].map(({ ref, actor }) => ({ ref, state: actor.snapshot })), - ); - }, - }; -} diff --git a/packages/core-agent/host/index.ts b/packages/core-agent/host/index.ts deleted file mode 100644 index 429abb9f..00000000 --- a/packages/core-agent/host/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./host.ts"; -export * from "./ports.ts"; diff --git a/packages/core-agent/host/notifications.ts b/packages/core-agent/host/notifications.ts deleted file mode 100644 index eed6ddf1..00000000 --- a/packages/core-agent/host/notifications.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { freeze } from "../fsm/fsm.ts"; - -/** - * Delivers a display event to an optional observer sink without letting the observer affect - * execution. Best-effort, immutable display data. Never await or route observer failures into - * decisions. The sink receives a frozen deep copy of `event`. Throws and rejections are swallowed, - * and an event that `structuredClone` cannot copy is silently dropped. - */ -export function notify(sink: ((event: T) => unknown) | undefined, event: T): void { - if (!sink) return; - try { - void Promise.resolve(sink(freeze(structuredClone(event)))).catch(() => {}); - } catch {} -} diff --git a/packages/core-agent/host/ports.ts b/packages/core-agent/host/ports.ts deleted file mode 100644 index 34d2435a..00000000 --- a/packages/core-agent/host/ports.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { isAbsolute } from "node:path"; - -import { z } from "zod"; - -import { createChatCompletion, type PreparedModel } from "../agent/agent.ts"; -import { - MediaKindSchema, - type BlobRef, - type BlobResolver, - type MediaKind, -} from "../agent/content.ts"; -import { AgentIdSchema, ToolNameSchema, type CompletionSchema } from "../agent/types.ts"; -import type { EffectEmitter } from "../effects/index.ts"; -import { freeze } from "../fsm/fsm.ts"; -import type { StreamDeltaSink } from "../providers/types.ts"; -import type { CompletionUsage } from "../providers/usage.ts"; - -/** - * One part of a rich tool result `run` may return instead of a JSON value: inline text, or bytes - * for the host to store as a blob before the result becomes a fact (D4: for example an MCP tool - * returning an image). `parts` are ordered; `bytes` are content-addressed by the host on storage. - */ -export type ToolOutputPart = - | Readonly<{ type: "text"; text: string }> - | Readonly<{ type: "blob"; bytes: Uint8Array; media: MediaKind; name?: string }>; - -/** - * Rich tool output: `run` may return `{ text, parts }` instead of a plain JSON value to attach - * blob content. `text` must equal the concatenation of the text parts; blob parts carry raw bytes - * that {@link ExecutionBindings.storeBlob} stores before the tool result commits as a fact. - */ -export type ToolOutput = Readonly<{ text: string; parts: readonly ToolOutputPart[] }>; - -const ToolOutputPartSchema = z.discriminatedUnion("type", [ - z.strictObject({ type: z.literal("text"), text: z.string() }), - z.strictObject({ - type: z.literal("blob"), - bytes: z.instanceof(Uint8Array), - media: MediaKindSchema, - name: z.string().min(1).optional(), - }), -]); - -/** - * Validates a raw {@link ToolOutput} a tool's `run` returned, before blob parts are stored. - * Distinguishes it from a plain JSON output: both `text` and `parts` (possibly empty) must be - * present, with no other keys. - */ -export const ToolOutputSchema = z - .strictObject({ text: z.string(), parts: z.array(ToolOutputPartSchema) }) - .refine( - (output) => - output.text === - output.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""), - "Tool output text must equal its text parts", - ) - .readonly(); - -/** - * Display category of a tool (the ACP tool-call kinds). Adapters use it to pick a card or icon. - * It grants no authority and does not affect permission or execution. - */ -export const ToolKindSchema = z.enum([ - "read", - "edit", - "delete", - "move", - "search", - "execute", - "think", - "fetch", - "switch_mode", - "other", -]); - -/** Display category of a tool; see {@link ToolKindSchema}. */ -export type ToolKind = z.infer; - -/** - * A file a tool call reads or changes, shown to the user (for example to follow along in an - * editor). Display metadata only; it is not a sandbox or an access rule. `path` must be absolute. - */ -export const ToolLocationSchema = z - .strictObject({ - path: z.string().refine(isAbsolute, "Tool location must be an absolute path"), - line: z.number().int().nonnegative().optional(), - }) - .readonly(); - -/** A file a tool call reads or changes, for display; see {@link ToolLocationSchema}. */ -export type ToolLocation = z.infer; - -/** - * Identity of one live tool invocation, passed to {@link Tool.run}. - * Ephemeral display identity for this live invocation; never journaled or an authorization grant. - */ -export type ToolRunContext = Readonly<{ - /** - * Unique operation ID of this call, `${batchId}/${callId}`. Matches the permission request's - * `toolCall.toolCallId` and the tool notifications. - */ - toolCallId: string; - sessionId?: string; - turnId?: string; - /** The tool batch (all calls proposed by one step) this call belongs to. */ - batchId?: string; - /** The provider's call ID, unique only within its tool batch. */ - callId?: string; -}>; - -/** - * Existential tool adapter: defineTool retains input-schema inference at the authoring boundary. - * Successful outputs must be a JSON value or a {@link ToolOutput} (text plus blob parts). The host - * rejects undefined, Date, class instances, functions and other non-JSON, non-`ToolOutput` values - * as correlated failed tool outcomes. Return null for no value. Strings pass through; other JSON - * values are serialized for model tool messages. - */ -export type Tool = Readonly<{ - /** Sent to the model together with {@link Tool.parameters}. */ - description?: string; - kind?: ToolKind; - /** - * Pure display metadata derived from the parsed input. May run before permission is granted. - * A throw or invalid location is logged and ignored. - */ - locations?: (input: unknown) => readonly ToolLocation[]; - /** JSON Schema for the tool's arguments, advertised to the model. */ - parameters: Record; - /** - * Validates the model's raw arguments. Runs before permission is requested and before `run`. - * A rejection is an `invalid_input` failure for this call; `run` is not called. - */ - parseInput: (raw: unknown) => Promise; - /** - * Performs the tool's effect with the parsed input. The signal aborts on cancellation or when the - * configuration's tool deadline (`toolTimeoutMs`) expires. A tool that ignores it may still - * change the outside world, but its late result is discarded. Throwing or rejecting fails this - * call. `context` is absent when the tool is called directly, outside the host. Return a - * {@link ToolOutput} to attach blob content (for example an image); the host stores each blob's - * bytes through {@link ExecutionBindings.storeBlob} before the result commits. - */ - run: ( - input: unknown, - signal: AbortSignal, - context?: ToolRunContext, - ) => unknown | Promise; -}>; - -/** - * Builds a {@link Tool} from a Zod input schema. - * `parameters` is the schema's JSON Schema (input side), `parseInput` validates with the schema, - * and `run` and `locations` receive its parsed output type. `kind` defaults to `"other"`. - * - * @throws When `kind` is not a {@link ToolKind}. - */ -export function defineTool(definition: { - input: S; - description?: string; - kind?: ToolKind; - /** Pure display metadata derived from parsed input; no I/O or execution authorization. */ - locations?: (input: z.output) => readonly ToolLocation[]; - run: ( - input: z.output, - signal: AbortSignal, - context?: ToolRunContext, - ) => unknown | Promise; -}): Tool { - const { input, description, locations, run } = definition; - const kind = ToolKindSchema.parse(definition.kind ?? "other"); - return Object.freeze({ - description, - kind, - ...(locations ? { locations: (value: unknown) => locations(value as z.output) } : {}), - parameters: z.toJSONSchema(input, { io: "input" }), - parseInput: (raw: unknown) => input.parseAsync(raw), - run: (value: unknown, signal: AbortSignal, context?: ToolRunContext) => - run(value as z.output, signal, context), - }); -} - -/** - * An agent in a session's registry: its default model, instructions, the tools it may call and - * the agents it may hand off to. - */ -export const AgentDefinitionSchema = z - .strictObject({ - /** Model name used when the configuration selects none. */ - model: z.string().min(1), - /** - * Agents this agent may hand off to. Omitted means every registered agent, including itself; - * `[]` disables handoff. - */ - successors: z.array(AgentIdSchema).readonly().optional(), - /** - * Instructions for this agent, placed as a `system` role message ahead of the projected prompt - * (not a system notice). - */ - systemPrompt: z.string().optional(), - /** Names of registered tools this agent may call. */ - tools: z.array(ToolNameSchema).default([]).readonly(), - }) - .readonly(); - -/** Input shape of {@link AgentDefinitionSchema}; `tools` may be omitted. */ -export type AgentDefinition = z.input; - -/** - * What a {@link CompletionPort} returns for one step. The host validates it as untrusted input - * before the turn can use it. - */ -export type CompletionPortResponse = Readonly<{ - /** - * The model's answer, tool calls or handoff. Tool names, handoff targets and call IDs are checked - * against the agent. - */ - completion: z.input; - /** - * Provider continuation data (for example thinking signatures) to replay with this assistant - * message in later requests to the same provider. Must be JSON, at most 64 KiB. It is stored - * before the step succeeds; a storage failure fails the step. - */ - continuationPayload?: unknown; - /** - * Accounting reported by the provider for this response; not cumulative, not current context - * size. - */ - usage?: CompletionUsage; -}>; - -/** - * Performs one step: sends a prepared completion request to a model and returns its response. - * Should return a {@link CompletionPortResponse}; a bare completion without the wrapper is also - * accepted. The host validates the result before admitting it, and a throw or rejection fails the - * step. A result that arrives after `signal` aborts is discarded. - * - * @param blobs - Resolver for attachments and continuations, scoped to this operation. Do not - * retain it. - * @param onDelta - Display-only stream sink. Partial output never counts as the response. - * @param correlation - Identifies the operation for transport capture. `generation` is the turn's - * operation counter, not a step number. - */ -export type CompletionPort = ( - request: PreparedModel, - signal: AbortSignal, - blobs?: BlobResolver, - onDelta?: StreamDeltaSink, - correlation?: Readonly<{ - sessionId?: string; - turnId?: string; - childId?: string; - generation?: number; - }>, -) => unknown | Promise; - -/** - * The answer a {@link PermissionPort} must return: one of the offered option IDs, or `cancelled`. - * Anything else fails closed. - */ -export const PermissionResponseSchema = z.strictObject({ - outcome: z.discriminatedUnion("outcome", [ - z.strictObject({ - outcome: z.literal("selected"), - optionId: z.enum(["allow-once", "allow-session", "reject-once"]), - }), - z.strictObject({ outcome: z.literal("cancelled") }), - ]), -}); - -/** - * One approval question: may this tool call run? The host asks once per call, in call order, after - * the call's input has validated. - */ -export type PermissionRequest = Readonly<{ - sessionId?: string; - turnId: string; - /** `${permissionOperationId}/${callId}`; unique per question. */ - requestId: string; - /** - * Display data for the call. `toolCallId` matches {@link ToolRunContext.toolCallId} and tool - * notifications. - */ - toolCall: Readonly<{ - toolCallId: string; - title: string; - name: string; - kind: ToolKind; - status: "pending"; - rawInput: unknown; - locations?: readonly ToolLocation[]; - }>; - /** - * Always the three choices `allow-once`, `allow-session` (this tool, any arguments, until the - * host closes or its remembered grants are reset) and `reject-once`. - */ - options: readonly Readonly<{ - optionId: "allow-once" | "allow-session" | "reject-once"; - name: string; - kind: "allow_once" | "allow_always" | "reject_once"; - }>[]; -}>; -/** - * Asks the user whether a tool call may run. Used only when the configuration's permissions are - * `ask`; tools with a remembered `allow-session` grant are not asked again. - * Authoritative response, unlike display sinks. Exceptions and malformed responses fail closed. - * Every call with valid input must be allowed before the tool batch runs; asking stops at the first - * call that is not allowed. `signal` aborts when the turn cancels, and a late answer cannot start a - * tool. - */ -export type PermissionPort = ( - request: PermissionRequest, - signal: AbortSignal, -) => unknown | Promise; - -/** The external resources a host executes with. Kept out of snapshots and the journal. */ -export type ExecutionBindings = Readonly<{ - /** Agent registry, keyed by agent ID. */ - agents: ReadonlyMap; - /** Tool registry, keyed by tool name. */ - tools?: ReadonlyMap; - complete: CompletionPort; - /** Required when permissions are `ask`; without it every permission request fails. */ - requestPermission?: PermissionPort; - /** - * Stores blob bytes a tool's {@link ToolOutput} returned, before the tool result commits as a - * fact. Required when any registered tool may return blob parts; a tool result carrying one - * without it fails the call. - */ - storeBlob?: (bytes: Uint8Array, meta: { media: MediaKind; name?: string }) => Promise; - /** - * Subscriber for this host's effect events (child lifecycle, command dispatch, tool admission, - * permission decisions). Always combined with the default diagnostics subscriber, never - * replacing it; omit to run with logging only. - */ - effects?: EffectEmitter; -}>; -/** - * A {@link CompletionPort} that posts OpenAI chat-completions requests to `baseUrl`. - * Connection settings remain in the transport closure, never in the session journal. - * It ignores `blobs` and `onDelta` and returns a bare completion, without continuation or usage. - * Prefer a versioned provider profile binding for new environments. - * - * @throws When `baseUrl` is not an http(s) URL. - */ -export function completionTransport(options: { - baseUrl: string; - apiKey?: string; - fetch?: typeof fetch; -}): CompletionPort { - const baseUrl = z.url({ protocol: /^https?$/ }).parse(options.baseUrl); - const { apiKey, fetch: fetcher } = options; - return (request, signal) => - createChatCompletion({ ...request, baseUrl, apiKey, signal }, fetcher); -} - -/** - * Validates and copies the agent and tool registries of `bindings`. Tool parameter schemas are - * cloned and frozen, and a missing tool `kind` becomes `"other"`. - * - * @throws When an agent ID, tool name or agent definition is invalid, or an agent names an - * unregistered successor or tool. - */ -export function copyRegistries(bindings: Pick) { - const agents = new Map( - [...bindings.agents].map(([id, definition]) => [ - AgentIdSchema.parse(id), - AgentDefinitionSchema.parse(definition), - ]), - ); - const tools = new Map( - [...(bindings.tools ?? [])].map(([name, tool]) => [ - ToolNameSchema.parse(name), - Object.freeze({ - ...tool, - kind: ToolKindSchema.parse(tool.kind ?? "other"), - parameters: freeze(structuredClone(tool.parameters)), - }), - ]), - ); - for (const agent of agents.values()) - for (const successor of agent.successors ?? []) - if (!agents.has(successor)) throw new Error("Unknown successor agent"); - for (const agent of agents.values()) - for (const name of agent.tools) if (!tools.has(name)) throw new Error(`Unknown tool: ${name}`); - return { agents, tools }; -} diff --git a/packages/core-agent/host/testing/completion-contract.ts b/packages/core-agent/host/testing/completion-contract.ts deleted file mode 100644 index ba4ab21d..00000000 --- a/packages/core-agent/host/testing/completion-contract.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../../agent/agent.ts"; -import { createOperationActor } from "../../agent/operation-actor.ts"; -import { deferred, until } from "../../agent/test-support.ts"; -import { CompletionSchema, ref, type Result } from "../../agent/types.ts"; -import type { CompletionPort } from "../ports.ts"; - -/** Factory binds a scripted completion source. Validation and cancellation are host guarantees. */ -export function completionContract( - name: string, - factory: (source: CompletionPort) => CompletionPort, -) { - describe(name, () => { - const request = PreparedModelSchema.parse({ - model: "test", - messages: [{ role: "user", content: "hello" }], - }); - function execute(port: CompletionPort) { - const result = deferred>(); - const actor = createOperationActor( - ref("completion", "contract/completion"), - { - input: request, - parseInput: PreparedModelSchema.parse, - run: port, - parseOutput: CompletionSchema.parse, - }, - result.resolve, - ); - void actor.start(); - return { actor, result: result.promise }; - } - test("passes the validated request and signal; validates settled output", async () => { - const operation = execute( - factory((received, signal) => { - expect(received).toEqual(request); - expect(signal.aborted).toBe(false); - return { kind: "answer", text: "done" }; - }), - ); - expect(await operation.result).toMatchObject({ - kind: "succeeded", - value: { kind: "answer", text: "done" }, - }); - }); - test("malformed output and thrown failures cannot become success", async () => { - expect(await execute(factory(() => ({ invalid: true }))).result).toMatchObject({ - kind: "failed", - }); - expect( - await execute( - factory(() => { - throw new Error("offline"); - }), - ).result, - ).toMatchObject({ kind: "failed", error: { message: "offline" } }); - }); - test("cancellation signals the adapter and ignores a late success", async () => { - const pending = deferred(); - let signal: AbortSignal | undefined; - const operation = execute( - factory((_, supplied) => { - signal = supplied; - return pending.promise; - }), - ); - await until(() => Boolean(signal)); - await operation.actor.cancel(); - expect(signal!.aborted).toBe(true); - expect(await operation.result).toEqual({ kind: "cancelled" }); - pending.resolve({ kind: "answer", text: "late" }); - await pending.promise; - expect(operation.actor.snapshot.status).toBe("cancelled"); - }); - }); -} diff --git a/packages/core-agent/host/testing/tool-contract.ts b/packages/core-agent/host/testing/tool-contract.ts deleted file mode 100644 index 571f7c3c..00000000 --- a/packages/core-agent/host/testing/tool-contract.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { createOperationActor } from "../../agent/operation-actor.ts"; -import { deferred, until } from "../../agent/test-support.ts"; -import { ref, type Result } from "../../agent/types.ts"; -import type { Tool } from "../ports.ts"; - -/** Factory supplies a tool accepting {text:string}; execution is always behind input validation. */ -export function toolContract(name: string, factory: (run: Tool["run"]) => Tool) { - describe(name, () => { - function execute(tool: Tool, input: unknown) { - const result = deferred>(); - const actor = createOperationActor( - ref("tool", "contract/tool"), - { - input, - parseInput: tool.parseInput, - run: tool.run, - parseOutput: (value) => z.json().parse(value), - }, - result.resolve, - ); - void actor.start(); - return { actor, result: result.promise }; - } - test("invalid inputs never invoke execution", async () => { - let calls = 0; - const tool = factory(() => { - calls++; - return "done"; - }); - expect((await execute(tool, { text: 42 }).result).kind).toBe("failed"); - expect(calls).toBe(0); - expect((await execute(tool, { text: "ok" }).result).kind).toBe("succeeded"); - expect(calls).toBe(1); - }); - test("execution errors and non-JSON outputs are failed results", async () => { - expect( - ( - await execute( - factory(() => { - throw new Error("broken"); - }), - { text: "ok" }, - ).result - ).kind, - ).toBe("failed"); - expect( - ( - await execute( - factory(() => Symbol("invalid")), - { text: "ok" }, - ).result - ).kind, - ).toBe("failed"); - }); - test("independent tool cancellation cannot settle another operation", async () => { - const pending = deferred(); - const signals: AbortSignal[] = []; - const tool = factory((_, signal) => { - signals.push(signal); - return pending.promise; - }); - const first = execute(tool, { text: "a" }); - const second = execute(tool, { text: "b" }); - await until(() => signals.length === 2); - await first.actor.cancel(); - expect(signals[0]!.aborted).toBe(true); - expect(signals[1]!.aborted).toBe(false); - pending.resolve("done"); - expect((await first.result).kind).toBe("cancelled"); - expect((await second.result).kind).toBe("succeeded"); - }); - }); -} diff --git a/packages/core-agent/host/tool-updates.test.ts b/packages/core-agent/host/tool-updates.test.ts deleted file mode 100644 index 03d8c7b9..00000000 --- a/packages/core-agent/host/tool-updates.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { deferred, until } from "../agent/test-support.ts"; -import { ActorIdSchema, CompletionSchema, ref } from "../agent/types.ts"; -import { - createHost, - type HostToolNotification, - type HostToolOutcome, - type ToolUpdateSink, -} from "./host.ts"; -import { defineTool, type Tool } from "./ports.ts"; - -function setup(tool: Tool, sink?: ToolUpdateSink) { - const updates: HostToolNotification[] = []; - const outcomes: HostToolOutcome[] = []; - const turns: unknown[] = []; - const host = createHost( - { - sessionId: "session", - agents: new Map([["a", { model: "m", tools: ["work"] }]]), - tools: new Map([["work", tool]]), - complete: () => null, - }, - { - turn: (_, event) => { - turns.push(event); - }, - tool: (outcome) => { - outcomes.push(outcome); - }, - toolUpdate: (event) => { - updates.push(event); - return sink?.(event); - }, - }, - ); - function start(args: unknown = { path: "/tmp/DESIGN.md" }, batch = "batch") { - const completion = CompletionSchema.parse({ - kind: "tools", - text: "", - calls: [{ id: "call", name: "work", args }], - }); - if (completion.kind !== "tools") throw new Error("Expected tools"); - host.dispatch( - { - type: "turn", - turnId: ActorIdSchema.parse("turn"), - command: { type: "run_tools", child: ref("batch", batch), completion }, - }, - { projectPrompt: () => ({ messages: [], pointers: [] }) }, - ); - } - function cancel(batch = "batch") { - host.dispatch( - { - type: "turn", - turnId: ActorIdSchema.parse("turn"), - command: { type: "cancel", child: ref("batch", batch) }, - }, - { projectPrompt: () => ({ messages: [], pointers: [] }) }, - ); - } - return { host, updates, outcomes, turns, start, cancel }; -} - -const statuses = (updates: HostToolNotification[]) => - updates.flatMap((update) => (update.status ? [update.status] : [])); - -test("tool locations use parsed input and arrive before run; display completion cannot release a batch", async () => { - const order: string[] = []; - const tool = defineTool({ - input: z.object({ - path: z.string().transform((path) => `/tmp/${path}`), - line: z.number().default(4), - }), - kind: "read", - locations: (args) => { - order.push("locations"); - const path = args.path; - args.path = "changed only in metadata copy"; - return [{ path, line: args.line }]; - }, - run: (args, _signal, context) => { - expect(context).toMatchObject({ toolCallId: updates[0]!.toolCallId }); - expect(Object.isFrozen(context)).toBe(true); - order.push("run"); - expect(args).toEqual({ path: "/tmp/DESIGN.md", line: 4 }); - return { reviewed: true }; - }, - }); - const { host, updates, outcomes, turns, start } = setup(tool, (event) => { - order.push( - event.sessionUpdate === "tool_call_update" && event.locations - ? "scope" - : (event.status ?? "update"), - ); - expect(Object.isFrozen(event)).toBe(true); - }); - start({ path: "DESIGN.md" }); - await until(() => outcomes.length === 1); - expect(order).toEqual(["pending", "locations", "scope", "in_progress", "run", "completed"]); - expect(updates[0]).toMatchObject({ - sessionUpdate: "tool_call", - sessionId: "session", - turnId: "turn", - batchId: "batch", - callId: "call", - title: "work", - name: "work", - kind: "read", - status: "pending", - rawInput: { path: "DESIGN.md" }, - }); - expect(updates[1]).toMatchObject({ - sessionUpdate: "tool_call_update", - locations: [{ path: "/tmp/DESIGN.md", line: 4 }], - }); - expect(updates[2]).toHaveProperty("name", "work"); - expect(updates.at(-1)).toMatchObject({ status: "completed", rawOutput: '{"reviewed":true}' }); - expect(new Set(updates.map((update) => update.toolCallId)).size).toBe(1); - expect(turns).toEqual([]); - host.releaseTool(outcomes[0]!); - await until(() => turns.length === 1); - expect(updates).toHaveLength(4); - host.close(); -}); - -test("input, execution, and output failures report a single failed update", async () => { - for (const mode of ["input", "run", "output"] as const) { - let executions = 0; - const { host, start, outcomes, updates } = setup( - defineTool({ - input: z.object({ path: z.string() }), - run: () => { - executions++; - if (mode === "run") throw new Error("run failed"); - return undefined; - }, - }), - ); - start(mode === "input" ? {} : { path: "anything" }); - await until(() => outcomes.length === 1); - expect(statuses(updates)).toEqual( - mode === "input" ? ["pending", "failed"] : ["pending", "in_progress", "failed"], - ); - expect(executions).toBe(mode === "input" ? 0 : 1); - expect(updates[0]).toMatchObject({ kind: "other" }); - expect(outcomes[0]?.result.kind).toBe("failed"); - host.close(); - } -}); - -test("cancellation during validation or execution reports failed once and ignores late output/locations", async () => { - for (const phase of ["validation", "execution"] as const) { - const gate = deferred(); - let entered = false; - let runs = 0; - const tool = defineTool({ - input: z.object({ path: z.string() }).superRefine(async () => { - if (phase === "validation") { - entered = true; - await gate.promise; - } - }), - locations: ({ path }) => [{ path }], - run: async () => { - runs++; - entered = true; - if (phase === "execution") await gate.promise; - return "late"; - }, - }); - const { host, start, cancel, updates, outcomes } = setup(tool); - start(); - await until(() => entered); - cancel(); - await until(() => outcomes.length === 1); - expect(outcomes[0]?.result.kind).toBe("cancelled"); - expect(statuses(updates)).toEqual( - phase === "validation" ? ["pending", "failed"] : ["pending", "in_progress", "failed"], - ); - const count = updates.length; - gate.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(updates).toHaveLength(count); - expect(runs).toBe(phase === "validation" ? 0 : 1); - host.close(); - } -}); - -test("invalid display metadata and throwing/rejecting/never-resolving sinks cannot decide tool results", async () => { - for (const behavior of ["throw", "reject", "pending"] as const) { - const { host, start, outcomes, updates } = setup( - defineTool({ - input: z.object({ path: z.string() }), - locations: () => [{ path: "relative" }], - run: () => "done", - }), - () => { - if (behavior === "throw") throw new Error("observer failed"); - if (behavior === "reject") return Promise.reject(new Error("observer rejected")); - return new Promise(() => {}); - }, - ); - start(); - await until(() => outcomes.length === 1); - expect(outcomes[0]?.result).toEqual({ kind: "succeeded", value: { text: "done" } }); - expect(statuses(updates)).toEqual(["pending", "in_progress", "completed"]); - host.close(); - } -}); - -test("same provider call id in separate batches has distinct notification identity", async () => { - const { host, start, updates, outcomes } = setup( - defineTool({ input: z.object({}), run: () => "ok" }), - ); - start({}, "batch1"); - start({}, "batch2"); - await until(() => outcomes.length === 2); - const starts = updates.filter((update) => update.sessionUpdate === "tool_call"); - expect(starts.map((update) => String(update.callId))).toEqual(["call", "call"]); - expect(new Set(starts.map((update) => update.toolCallId)).size).toBe(2); - for (const start of starts) - expect(statuses(updates.filter((event) => event.toolCallId === start.toolCallId))).toEqual([ - "pending", - "in_progress", - "completed", - ]); - host.close(); -}); diff --git a/packages/core-agent/index.ts b/packages/core-agent/index.ts deleted file mode 100644 index 69f646b3..00000000 --- a/packages/core-agent/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Public entry point for journaled sessions. Other modules have explicit package subpaths. */ -export * from "./session/index.ts"; diff --git a/packages/core-agent/logging/README.md b/packages/core-agent/logging/README.md deleted file mode 100644 index 0491cfbf..00000000 --- a/packages/core-agent/logging/README.md +++ /dev/null @@ -1,239 +0,0 @@ -# Operational logging - -Logs explain why work stopped; the journal establishes what committed; provider captures show what -was actually sent and received. Use the evidence appropriate to the question. A log line cannot -release a persistence gate, and a journal cannot contain a response rejected before admission. - -Configure LogTape once in your launcher before creating sessions. Without configuration, core's -diagnostics are silent. Logging belongs to the environment because several sessions can share a -sink: closing one session must not dispose the others' evidence. The logging helpers are portable; -the core package as a whole has a Bun runtime contract. - -Every log record below is produced by the default subscriber of an [effect -event](../effects/README.md), not by a second, independent emission path. `ExecutionBindings.effects` -and `TransportBinding.effects` add subscribers alongside that default; they never replace it, so -binding one for usage accounting, auditing or a hook never silences these logs. - -## Investigate a stopped turn - -Start with the public terminal failure's operation identity and cause. Find that session/turn/child -in the durable log. A WARNING/ERROR-only scan should explain the exception itself; INFO/DEBUG adds -the sequence. `permission.waiting` means a user decision is outstanding, `tool.awaiting_release` -means a result is awaiting its runtime gate, and `child.timed_out` names an expired limit. Treating -all three as “still running” would send an operator to the wrong component. - -For HTTP or parse failures, follow the provider request ID and operation into an opt-in -[traffic capture](../environment/README.md#retained-provider-traffic). Do not reconstruct a supposed -HTTP request from projected journal messages: encoding, streaming, and response failure matter. - -## Configure delivery - -For an embedded development process, a console sink is enough to inspect the events. A launched -agent also needs a bounded durable sink, a documented location, and retention. This example only -illustrates routing; it does not supply that production storage: - -```ts -import { configure, getConsoleSink, reset } from "@logtape/logtape"; - -async function setLogging(verbose: boolean) { - await configure({ - reset: true, - sinks: { console: getConsoleSink() }, - loggers: [ - { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["console"] }, - { category: ["labkit"], lowestLevel: "info", sinks: ["console"] }, - { - category: ["labkit", "persistence"], - lowestLevel: verbose ? "debug" : "warning", - sinks: ["console"], - parentSinks: "override", - }, - ], - }); -} - -await setLogging(false); -await setLogging(true); // Existing loggers use the new configuration. -// At environment shutdown, after sessions have closed: -await reset(); -``` - -Serialize configuration changes in the environment. Configuration is global to the -LogTape instance, so applications using other LogTape libraries must include their -categories in the same configuration. Reset disposes disposable sinks: create fresh -resource-owning sinks when reconfiguring, and never reuse a disposed stream. A single -session closing must not dispose environment resources. - -## Destinations and levels - -A logger can list multiple sink names. Each sink receives a structured LogRecord. -Use getConsoleSink for interactive output, getStreamSink with getJsonLinesFormatter -for a WritableStream of JSONL, or a custom sink for a UI buffer or test capture. -File and OpenTelemetry adapters can be bound by the environment when needed; core -does not import filesystem APIs or select file locations. - -Sink inheritance is explicit: parentSinks: "override" makes a category's routing and -threshold independent of the parent. lowestLevel: null disables a category. -For example, keep session logs at info while routing host debug logs to a bounded -UI buffer. Bound custom buffers and choose a dropping/backpressure policy in the -environment; do not let an unbounded capture accumulate during a long experiment. - -Custom sinks must implement LogTape's synchronous Sink interface. Use fromAsyncSink -for asynchronous destinations; do not pass an unwrapped async function as a sink. -The diagnostic helper catches synchronous logging failures. Diagnostic delivery is -best effort and does not gate a command or certify a commit. - -## Event contract - -- session: admission receipts, observed state, terminal settlement, stop, restore, - recovery, uncertain-append reconciliation, and configuration: `configuration.selected` (stored; - says whether a running turn defers it) and `configuration.applied` (the committed record, with - the same `selectionId`) at Info, `configuration.unchanged` at Info (a selection equal to the - policy in force; `revokesGrants` says whether approvals were revoked anyway), - `configuration.rejected` at Warning, `configuration.store_failed` at Error. -- host: command dispatch, operation start/settlement, cancellation requests, and - tool-result release after persistence. -- persistence: append/load attempts, classified outcomes, revision and append identity. -- provider: completion child start/settlement, including injected non-HTTP completions. - -Debug carries routine operation detail. Info carries terminal/session lifecycle -events. Warning carries failed children, unsuccessful storage outcomes, reconciliation, -and recovery. Error reports failed session status/admission. - -Severity must support a warning/error-only investigation. `permission.decided` stays Info for -all valid choices; `permission.refused` is a separate Warning identifying the intervention: -`decision=reject_once`, `reasonCode=permission_refused`, `operation=tool_execution`, -`outcome=blocked`, tool name/kind, arguments, locations, and correlation IDs (session, turn, -batch, call). It reports a refusal received through the permission port for one call, not a -durable journal receipt or a claim that the tool ran; other calls in the same batch are logged -independently and are unaffected. Refusals remain distinguishable from cancelled permission -dialogs. - -`turn.settled` is Warning only for a failed agent turn. Completed, aborted, and exhausted turns -are Info; expected negative test scenarios use the same production severity, not a test-specific -threshold. The failed-turn record names the operation and agent, explains the failure in `message` -and `reason`, retains `error`, and identifies the triggering event and child when available. -A refusal warning records the intervention; the turn continues, so no failed-turn warning follows -it. Do not suppress the category to hide -an unexplained warning; make the cause and scope legible on the warning itself. - -### Common fields and correlation - -Use the same field name for the same identity across modules. Include identities that -exist at that boundary; omit unavailable values rather than inventing placeholder IDs. - -| Field | Meaning and join | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `sessionId` | Durable conversation identity; joins ACP, session, host and provider records. | -| `turnId` | Core turn identity within the session; joins preparation, completion, tools and settlement. | -| `childId` | Host operation identity; links command dispatch, provider transport, cancellation and child settlement. | -| `batchId`, `callId` | Core tool batch and model-supplied call identities. Keep the batch/child context: a raw call ID alone need not be globally unique. | -| `toolCallId` | ACP tool identity exposed to the client; joins tool cards, permission requests and delegated client operations. Do not substitute a raw `callId` for this identity. | -| `requestId` | Core submission/admission request identity; joins receipt and append handling. It is not an HTTP or ACP request ID. | -| `appendId` | Stable persistence attempt identity; joins append, receipt and uncertain-append reconciliation. | -| `rpcRequestId` | ACP JSON-RPC request identity, scoped to `connectionId`; distinct from a core submission. | -| `httpRequestId` | Locally assigned provider HTTP attempt identity; joins transport start, response, decode and failure. | -| `providerRequestId` | Provider-assigned response/request identity, when returned; retain it for provider support and server-side investigation. | -| `connectionId` | Adapter connection identity; disambiguates reused JSON-RPC request numbers and connection lifetime. | -| `durationMs` | Elapsed operation time in milliseconds. Use this name consistently, not `elapsedMs`; timeout limits remain `timeoutMs`. | -| `revision`, `expectedRevision` | Observed and requested journal positions; diagnostic observations do not certify persistence. | -| `error` | Nested `diagnosticError(error)` object: `name`, `message`, `stack`, `code`, `cause`, and available custom metadata. Do not spread these into top-level lifecycle fields. | - -An ACP prompt record connects `connectionId`/`rpcRequestId` to `sessionId`; session -admission connects that session to its core `requestId` and `turnId`. Host dispatch -connects `turnId` to `childId`; provider records connect the child to `httpRequestId` -and any `providerRequestId`. Tool records connect core batch/call identities to -`toolCallId`. These are joins, not interchangeable names for one universal request ID. -Standalone agent hosts may omit `sessionId`. Identifiers must not encode credentials. - -Add `operation`, `outcome`, `status`, `reason`, provider/profile version, configuration, -limits and counts where they explain the lifecycle event. Preserve the actual failure -cause under `error`; a status or generic message is not a replacement for that cause. - -Use `diagnostic(category, level, event, fields)` with a stable event name and structured -fields. Each record has `properties.event`; its human message renders those same fields, -so the default test reporter does not discard correlation or failure details. Supply -`message` or `reason` to explain a decision or wait, not only an event label. Avoid -repeating complete snapshots or successful response bodies on every transition. - -Use `diagnosticError(error, secretValues?)` to preserve error names, messages, stacks, -nested causes, codes and custom provider metadata. `redactDiagnostics(value, -secretValues?)` replaces credential fields (API keys, authorization, passwords and -credential tokens), recognizable credential text, and supplied exact secret values. -Transport adapters must pass configured API key values when a provider may echo them -in a free-text error. Paths, provider error explanations, request IDs, token usage, -limits and timings remain intact. Routine success events need no prompt or file body; -this is not permission to suppress error evidence. Cycles are explicitly marked. - -`diagnosticContext(category, fields)` returns an immutable, explicitly bound logger -function with signature `(level, event, extraFields?)`. Pass session and operation -context across async boundaries; no global mutable current-session context exists. -All helpers are browser-compatible and do not configure sinks. - -Logs run in adapters/runtime command handlers, outside pure decision functions. -They are not the durable journal, do not enter policy or restored state, and may -be dropped. Existing journal fixtures remain the behavioral contract. - -Tests use in-memory LogTape sinks to verify runtime reconfiguration, routing, sink -failure isolation, shared lifetime, correlation, and targeted credential redaction: - -```sh -bun test packages/core-agent/logging -``` - -## Inspect logs from tests - -Tests and reusable contract suites import Bun-compatible helpers from -`@logtape/testing-bun/autoload`. It configures async-local capture automatically; -no application logger setup or Bun preload is required. - -```sh -# Default: show info-and-higher records only for failing tests. -bun test - -# Show captured debug-and-higher records for every test. -LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun test -bun run test:agent:logs - -# Limit the run using ordinary Bun test arguments. -bun run test:agent:logs packages/core-agent/session/provider-runtime.test.ts - -# Suppress captured logs, including for failing tests. -LOGTAPE_TEST_MODE=never bun test -``` - -Modes are `on-failure` (default), `always`, and `never`. The default threshold is -`info`; `LOGTAPE_TEST_LOWEST_LEVEL` accepts LogTape levels such as `debug`, `info`, -and `warning`. Records are buffered for each test callback and printed when it -settles, rather than streamed live. Hooks are outside that callback capture. - -New tests should import `test` or `it` from the autoload entry point. Other helpers -such as `expect` and `describe` are re-exported there unchanged. Importing only -`bun:test` does not opt a test into capture. - -The logging-configuration tests deliberately use raw `bun:test` callbacks: they -replace and reset global routing to assert custom sink behavior. Those records go -to their assertion sinks instead of the failure reporter. Their cleanup restores -the async-local test configuration so subsequent wrapped tests still capture logs. - -This is a development-only dependency. It does not configure application logging -or change journal records. See the [LogTape testing guide](https://logtape.org/manual/testing) -for reporter customization. - -## Fixture diagnostic artifacts - -The Bun-only `fixture-capture.ts` helper exports -`withFixtureDiagnostics(directory, fields, callback)`. It captures the real LogTape -records at debug level in `diagnostics.jsonl` and a readable `diagnostics.log` beside -the scenario journals. JSONL uses the same flat schema as launcher diagnostics: ISO `timestamp`, `level`, -`category`, `event`, and structured event fields at the top level, including scenario/run -metadata supplied by the caller. These files are diagnostic -review artifacts, not approved journal baselines or evidence of a committed append. - -The environment must configure LogTape `contextLocalStorage` once; Bun's test autoload -already does this. Capture uses scoped routing, does not reset global logging, and -forwards records into the parent scope so normal test reporting still works. Concurrent -scenarios remain isolated. Files are written as events arrive, so a failing scenario -retains its evidence; an artifact write failure is reported after callback cleanup, -rather than disappearing into best-effort diagnostic delivery. Re-running a scenario -replaces its two diagnostic files. The fixture runner owns artifact retention. diff --git a/packages/core-agent/logging/fixture-capture.test.ts b/packages/core-agent/logging/fixture-capture.test.ts deleted file mode 100644 index 3269cbaa..00000000 --- a/packages/core-agent/logging/fixture-capture.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { getLogger, withConfig, type LogRecord } from "@logtape/logtape"; -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { withFixtureDiagnostics } from "./fixture-capture.ts"; -import { diagnostic } from "./index.ts"; - -test("fixture artifacts capture real correlated diagnostics and preserve parent routing", async () => { - const root = await mkdtemp(join(tmpdir(), "labkit-fixture-logs-")); - const parent: LogRecord[] = []; - try { - await withConfig( - { - sinks: { - parent: (record) => { - parent.push(record); - }, - }, - loggers: [{ category: ["labkit"], lowestLevel: "info", sinks: ["parent"] }], - }, - async () => { - await Promise.all( - ["first", "second"].map(async (scenario) => { - await withFixtureDiagnostics( - join(root, scenario), - { scenario, runId: "run-7" }, - async () => { - diagnostic("host", "debug", "tool.waiting", { - sessionId: scenario, - toolCallId: `${scenario}/tool-1`, - reason: "Awaiting permission", - path: "/workspace/core.ts", - apiKey: "secret-key", - }); - await new Promise((resolve) => setTimeout(resolve, scenario === "first" ? 5 : 1)); - diagnostic("host", "info", "tool.completed", { - sessionId: scenario, - durationMs: 12, - }); - }, - ); - }), - ); - getLogger(["labkit", "host"]).info("parent unaffected"); - }, - ); - for (const scenario of ["first", "second"]) { - const json = await readFile(join(root, scenario, "diagnostics.jsonl"), "utf8"); - const records = json - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(records).toHaveLength(2); - expect(records[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(records[0].event).toBe("tool.waiting"); - expect(records[0].category).toEqual(["labkit", "host"]); - expect(records[0].properties).toBeUndefined(); - expect(records.every((record) => record.sessionId === scenario)).toBe(true); - expect( - records.every((record) => record.scenario === scenario && record.runId === "run-7"), - ).toBe(true); - expect(json).not.toContain("secret-key"); - const text = await readFile(join(root, scenario, "diagnostics.log"), "utf8"); - for (const detail of [ - "tool.waiting", - "Awaiting permission", - "/workspace/core.ts", - scenario, - "run-7", - ]) - expect(text).toContain(detail); - } - expect(parent.map((record) => record.rawMessage)).toEqual([ - "tool.completed", - "tool.completed", - "parent unaffected", - ]); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test("failed fixture retains diagnostic files and original failure", async () => { - const directory = await mkdtemp(join(tmpdir(), "labkit-failed-fixture-")); - try { - const failure = new Error("fixture failed"); - await expect( - withFixtureDiagnostics(directory, { scenario: "failed" }, () => { - diagnostic("provider", "warning", "request.failed", { - sessionId: "session-9", - reason: "Provider HTTP 400", - providerRequestId: "req-99", - }); - throw failure; - }), - ).rejects.toBe(failure); - expect(await readFile(join(directory, "diagnostics.log"), "utf8")).toContain("req-99"); - expect(await readFile(join(directory, "diagnostics.jsonl"), "utf8")).toContain( - "Provider HTTP 400", - ); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); diff --git a/packages/core-agent/logging/fixture-capture.ts b/packages/core-agent/logging/fixture-capture.ts deleted file mode 100644 index ee5f4161..00000000 --- a/packages/core-agent/logging/fixture-capture.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Bun/Node fixture infrastructure only. Never imported by the browser-compatible index. -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -import { getConfig, getLogger, withConfig, type LogRecord } from "@logtape/logtape"; - -import { redactDiagnostics, type DiagnosticFields } from "./index.ts"; - -/** Capture real fixture diagnostics without replacing global or parent-test routing. */ -export async function withFixtureDiagnostics( - directory: string, - fields: DiagnosticFields, - callback: () => T, -): Promise> { - const storage = getConfig()?.contextLocalStorage; - if (!storage) { - throw new Error( - "Fixture diagnostic capture requires LogTape contextLocalStorage configuration", - ); - } - const parentContext = storage.getStore() ?? {}; - mkdirSync(directory, { recursive: true }); - const jsonPath = join(directory, "diagnostics.jsonl"); - const textPath = join(directory, "diagnostics.log"); - writeFileSync(jsonPath, ""); - writeFileSync(textPath, ""); - const failures: unknown[] = []; - let writeFailed = false; - const capture = (record: LogRecord): void => { - const captured = redactDiagnostics({ - ...record, - properties: { ...fields, ...record.properties }, - }) as LogRecord; - if (!writeFailed) { - try { - appendFileSync( - jsonPath, - `${JSON.stringify({ - timestamp: new Date(captured.timestamp).toISOString(), - level: captured.level, - category: captured.category, - event: captured.rawMessage, - ...captured.properties, - })}\n`, - ); - const properties = Object.entries(captured.properties) - .filter(([key, value]) => key !== "event" && value !== undefined) - .map(([key, value]) => `${key}=${JSON.stringify(value)}`) - .join(" "); - const event = captured.properties.event ?? captured.message.join(""); - appendFileSync( - textPath, - `${new Date(captured.timestamp).toISOString()} ${captured.level.toUpperCase()} ${captured.category.join(".")} ${event}${properties ? ` ${properties}` : ""}\n`, - ); - } catch (error) { - // diagnostic() isolates sink failures. Surface them after scenario cleanup instead. - writeFailed = true; - failures.push(error); - } - } - storage.run(parentContext, () => getLogger(record.category).emit(record)); - }; - let result: Awaited | undefined; - try { - result = await withConfig( - { - sinks: { fixture: capture }, - loggers: [ - { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["fixture"] }, - { category: ["labkit"], lowestLevel: "debug", sinks: ["fixture"] }, - ], - }, - callback, - ); - } catch (error) { - failures.push(error); - } - if (failures.length === 1) throw failures[0]; - if (failures.length > 1) - throw new AggregateError(failures, "Fixture execution and logging failed"); - return result as Awaited; -} diff --git a/packages/core-agent/logging/logging.test.ts b/packages/core-agent/logging/logging.test.ts deleted file mode 100644 index f45892e3..00000000 --- a/packages/core-agent/logging/logging.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -// These tests replace global routing, so they must not run inside a scoped log reporter. -import "@logtape/testing-bun/autoload"; - -import { afterEach, expect, test } from "bun:test"; - -import { getConfig } from "@logtape/logtape"; - -import { createSession, restoreSession } from "../session/session-runtime.ts"; -import { lostAcknowledgement, scriptedCompletion, testOptions } from "../session/test-support.ts"; -import { createMemoryPersistence } from "../session/testing/memory-persistence.ts"; -import { - configure, - diagnostic, - diagnosticContext, - diagnosticError, - redactDiagnostics, - reset, - type LogRecord, - type Sink, -} from "./index.ts"; - -const testLoggingConfig = getConfig()!; -afterEach(async () => { - await reset(); - await configure(testLoggingConfig); -}); - -async function capture(sink: Sink, level: "debug" | "warning" = "debug") { - await configure({ - reset: true, - sinks: { capture: sink }, - loggers: [ - { category: ["logtape", "meta"], lowestLevel: null }, - { category: ["labkit"], lowestLevel: level, sinks: ["capture"] }, - ], - }); -} - -test("runtime reconfiguration changes levels and routes already-used loggers", async () => { - const first: LogRecord[] = []; - const second: LogRecord[] = []; - await capture((record) => { - first.push(record); - }, "warning"); - diagnostic("host", "debug", "hidden"); - diagnostic("host", "warning", "visible"); - await configure({ - reset: true, - sinks: { - first: (record) => { - first.push(record); - }, - second: (record) => { - second.push(record); - }, - }, - loggers: [ - { category: ["logtape", "meta"], lowestLevel: null }, - { category: ["labkit"], lowestLevel: "warning", sinks: ["first"] }, - { - category: ["labkit", "host"], - lowestLevel: "debug", - sinks: ["second"], - parentSinks: "override", - }, - ], - }); - diagnostic("host", "debug", "now-visible"); - diagnostic("session", "warning", "session-warning"); - expect(first.map((record) => record.rawMessage)).toEqual(["visible", "session-warning"]); - expect(second.map((record) => record.rawMessage)).toEqual(["now-visible"]); -}); - -test("logs correlate commit-before-dispatch and exclude content and secrets", async () => { - const records: LogRecord[] = []; - await capture((record) => { - records.push(record); - }); - const options = testOptions({ - complete: scriptedCompletion([ - { - kind: "tools", - text: "PRIVATE_ASSISTANT", - calls: [{ id: "call-1", name: "echo", args: { text: "PRIVATE_TOOL" } }], - }, - { kind: "answer", text: "PRIVATE_ANSWER" }, - ]), - }); - const session = await createSession(options); - await session.input("PRIVATE_USER").settled; - const sessionId = session.snapshot.durable.conversation.sessionId; - const dispatches = records.filter((record) => record.rawMessage === "command.dispatched"); - expect(dispatches.length).toBeGreaterThan(0); - for (const dispatch of dispatches) { - const previous = records.slice(0, records.indexOf(dispatch)); - expect( - previous.some( - (record) => - record.rawMessage === "append.settled" && record.properties.outcome === "committed", - ), - ).toBe(true); - expect(dispatch.properties.sessionId).toBe(sessionId); - expect(dispatch.properties.turnId).toBeString(); - expect(dispatch.properties.childId).toBeString(); - } - expect( - records.some( - (record) => record.rawMessage === "tool.released" && record.properties.callId === "call-1", - ), - ).toBe(true); - expect(records.some((record) => record.category[1] === "provider")).toBe(true); - const restored = await restoreSession(options, sessionId); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); - await session.close(); - const count = records.length; - await restored.input("PRIVATE_FOLLOWUP").settled; - expect(records.length).toBeGreaterThan(count); - await restored.close(); - const serialized = JSON.stringify(records); - for (const secret of ["SECRET_SENTINEL", "PRIVATE_", "example.invalid", "parameters", "apiKey"]) { - expect(serialized).not.toContain(secret); - } -}); - -test("sink failures cannot fail a turn or close shared logging", async () => { - let calls = 0; - await capture(() => { - calls++; - throw new Error("sink unavailable"); - }); - const session = await createSession(testOptions()); - const result = await session.input("hello").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - await session.close(); - const before = calls; - diagnostic("host", "info", "still-configured"); - expect(calls).toBeGreaterThan(before); -}); - -test("lost acknowledgements preserve reconciliation identity and completion failure cause", async () => { - const records: LogRecord[] = []; - await capture((record) => { - records.push(record); - }); - const session = await createSession( - testOptions({ - persistence: lostAcknowledgement(createMemoryPersistence()), - complete: () => { - throw new Error("PRIVATE_ERROR"); - }, - }), - ); - const result = await session.input("hello").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("failed"); - expect( - records.some( - (record) => - record.rawMessage === "append.reconciling" && - typeof record.properties.appendId === "string", - ), - ).toBe(true); - expect(records.some((record) => record.level === "warning")).toBe(true); - expect(JSON.stringify(records)).toContain("PRIVATE_ERROR"); - await session.close(); -}); - -test("unconfigured diagnostics are silent", async () => { - await reset(); - expect(() => diagnostic("host", "error", "unconfigured")).not.toThrow(); -}); - -test("only environment reset disposes sinks; multiple sinks receive records", async () => { - let disposed = 0; - const first: LogRecord[] = []; - const second: LogRecord[] = []; - const sink = Object.assign( - (record: LogRecord) => { - first.push(record); - }, - { - [Symbol.dispose]() { - disposed++; - }, - }, - ); - await configure({ - reset: true, - sinks: { - first: sink, - second: (record) => { - second.push(record); - }, - }, - loggers: [ - { category: ["logtape", "meta"], lowestLevel: null }, - { category: ["labkit"], lowestLevel: "info", sinks: ["first", "second"] }, - ], - }); - const session = await createSession(testOptions()); - await session.close(); - expect(disposed).toBe(0); - expect(first.length).toBeGreaterThan(0); - expect(second).toEqual(first); - await reset(); - expect(disposed).toBe(1); -}); - -test("diagnostics render correlation and causes in human output and structured records", async () => { - const records: LogRecord[] = []; - await capture((record) => { - records.push(record); - }); - const log = diagnosticContext("provider", { sessionId: "session-7", turnId: "turn-2" }); - const cause = Object.assign(new Error("socket reset while contacting /v1/messages"), { - code: "ECONNRESET", - }); - const error = Object.assign(new Error("Anthropic request failed", { cause }), { - status: 400, - requestId: "req-123", - body: { error: { message: "thinking budget exceeds max_tokens" } }, - apiKey: "secret-key", - authorization: "Bearer private-token", - }); - log("warning", "provider.request.failed", { - childId: "child-3", - durationMs: 42, - error: diagnosticError(error), - }); - const record = records[0]!; - expect(record.properties.event).toBe("provider.request.failed"); - expect(record.properties.sessionId).toBe("session-7"); - const rendered = record.message.join(""); - for (const expected of [ - "session-7", - "turn-2", - "child-3", - "req-123", - "ECONNRESET", - "thinking budget exceeds max_tokens", - "400", - "42", - ]) { - expect(rendered).toContain(expected); - } - expect(rendered).not.toContain("secret-key"); - expect(rendered).not.toContain("private-token"); - expect((record.properties.error as { stack: string }).stack).toContain( - "Anthropic request failed", - ); -}); - -test("redaction targets credentials and preserves diagnostic details including exact-key echoes", () => { - const output = redactDiagnostics( - { - path: "/workspace/auth/token.ts", - maxOutputTokens: 4096, - tokenUsage: { output: 3000 }, - url: "https://example.invalid/messages?api_key=secret-value&model=sonnet", - body: "Provider rejected key arbitrary-provider-credential: invalid account scope", - headers: new Headers({ "x-api-key": "secret-value", "request-id": "req-5" }), - cause: new Error("failure details"), - }, - ["arbitrary-provider-credential"], - ); - const serialized = JSON.stringify(output); - for (const detail of [ - "/workspace/auth/token.ts", - "4096", - "3000", - "model=sonnet", - "invalid account scope", - "req-5", - "failure details", - ]) { - expect(serialized).toContain(detail); - } - expect(serialized).not.toContain("secret-value"); - expect(serialized).not.toContain("arbitrary-provider-credential"); - const cyclic: Record = {}; - cyclic.cause = cyclic; - expect(redactDiagnostics(cyclic)).toEqual({ cause: "[Circular]" }); -}); diff --git a/packages/core-agent/logging/test-capture.test.ts b/packages/core-agent/logging/test-capture.test.ts deleted file mode 100644 index db93e2e3..00000000 --- a/packages/core-agent/logging/test-capture.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { expect, test } from "@logtape/testing-bun/autoload"; - -// Exercise real child runners: this process has already initialized autoload and cannot -// re-read environment options safely. Temporary probes are not part of suite discovery. -test("test capture honors environment modes and levels after logging reconfiguration tests", async () => { - const directory = await mkdtemp(join(tmpdir(), "labkit-log-capture-")); - const fixture = join(directory, "capture.test.ts"); - try { - await Bun.write( - fixture, - ` -import { test } from ${JSON.stringify(import.meta.resolve("@logtape/testing-bun/autoload"))}; -import { getLogger } from ${JSON.stringify(import.meta.resolve("@logtape/logtape"))}; -import { diagnostic } from ${JSON.stringify(new URL("./index.ts", import.meta.url).pathname)}; -test("capture probe", () => { - const logger = getLogger(["labkit", "capture-probe"]); - logger.debug("CAPTURE_DEBUG_MARKER"); - diagnostic("capture-probe", "debug", "operation.waiting", { sessionId: "session-probe", childId: "child-probe", reason: "permission response pending", path: "/workspace/core.ts" }); - logger.warning("CAPTURE_WARNING_MARKER"); - if (process.env.LOGTAPE_PROBE_FAIL === "1") throw new Error("expected probe failure"); -}); -`, - ); - for (const [mode, level, fail, debug, warning] of [ - ["always", "debug", false, true, true], - ["always", "warning", false, false, true], - ["on-failure", "debug", false, false, false], - ["on-failure", "debug", true, true, true], - ["never", "debug", true, false, false], - ] as const) { - const child = Bun.spawn({ - cmd: [ - process.execPath, - "test", - new URL("./logging.test.ts", import.meta.url).pathname, - fixture, - ], - env: { - ...process.env, - LOGTAPE_TEST_MODE: mode, - LOGTAPE_TEST_LOWEST_LEVEL: level, - LOGTAPE_PROBE_FAIL: fail ? "1" : "0", - NO_COLOR: "1", - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - const output = stdout + stderr; - expect(exitCode).toBe(fail ? 1 : 0); - expect(output.includes("labkit·capture-probe CAPTURE_DEBUG_MARKER")).toBe(debug); - expect(output.includes("labkit·capture-probe CAPTURE_WARNING_MARKER")).toBe(warning); - for (const detail of [ - "session-probe", - "child-probe", - "permission response pending", - "/workspace/core.ts", - ]) { - expect( - output - .split("\n") - .some( - (line) => - line.includes("labkit·capture-probe operation.waiting") && line.includes(detail), - ), - ).toBe(debug); - } - } - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); diff --git a/packages/core-agent/package.json b/packages/core-agent/package.json deleted file mode 100644 index 0f704227..00000000 --- a/packages/core-agent/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@labkit/core-agent", - "version": "0.1.0", - "private": true, - "description": "Bun runtime for durable, inspectable agent sessions", - "type": "module", - "main": "./index.ts", - "types": "./index.ts", - "exports": { - ".": "./index.ts", - "./session": "./session/index.ts", - "./agent": "./agent/agent-runtime.ts", - "./types": "./agent/types.ts", - "./content": "./agent/content.ts", - "./host": "./host/index.ts", - "./effects": "./effects/index.ts", - "./providers": "./providers/index.ts", - "./policy": "./policy/index.ts", - "./environment": "./environment/index.ts", - "./environment/provider-capture": "./environment/provider-capture.ts", - "./logging": "./logging/index.ts", - "./fsm": "./fsm/index.ts", - "./testing": "./session/testing/memory-persistence.ts", - "./package.json": "./package.json" - }, - "files": [ - "index.ts", - "agent", - "session", - "host", - "effects", - "providers", - "policy", - "environment", - "logging", - "fsm", - "!**/*.test.ts", - "!**/test-support.ts", - "!session/fixtures", - "!session/fixture-runner.ts", - "!host/testing", - "!providers/testing", - "!session/testing/persistence-contract.ts" - ], - "dependencies": { - "@logtape/logtape": "catalog:", - "zod": "catalog:" - }, - "engines": { - "bun": ">=1.3.0" - }, - "devDependencies": { - "@logtape/testing-bun": "catalog:", - "prettier": "^3.8.3" - } -} diff --git a/packages/core-agent/policy/README.md b/packages/core-agent/policy/README.md deleted file mode 100644 index e5ffdb95..00000000 --- a/packages/core-agent/policy/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# Choosing behavior for the next turn - -Policy is the saved answer to “what is this session allowed to do?” Bindings provide executable -implementations and credentials. Keeping those separate makes a model/permission change auditable -without putting functions or secrets into the journal. - -Use `session.updatePolicy(patch)` and check its receipt. Changing configuration is two transitions. -Selecting validates the patch against the live registry and stores the result in the session's -configuration store at once, at any time, including while a turn runs; it is never `busy`. Applying -records a "configuration applied" `policy` record at the next boundary between turns, before any -queued input starts, so a running turn keeps the settings it started with. The receipt is `accepted` -when the conversation was idle and the record committed, `selected` when a turn is running and the -change waits for it to end, `ignored` when the selection matches the policy in force, and `failed` -when the patch is rejected or cannot be stored. `session.selectedPolicy` is what the next turn runs -under; `session.policy` is what is in force now. Mutating an options object does nothing to an open -session. - -## Choose how new input interacts with work - -| Choice | When it fits | Consequence to handle | -| ------------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `default@1` | Interactive replacement of a model request. | Input can replace a step in flight (including a handoff successor's step) in the same turn; input during tools/permission waiting is rejected. | -| `strict@1` | Each active turn must finish unchanged. | New input cannot barge in. | -| `queued@1` | Every accepted input needs its own turn. | Inputs persist in order; aborting the current turn does not discard its queued successors. | -| `abort-tools-on-user` input behavior | A new instruction should stop tool work first. | The successor is saved before cancellation and starts only after the aborted turn commits. Already-started effects cannot be undone. | -| `tolerant@1` | The model can use a tool error to choose its next action. | Tool input and execution failures, including an expired tool deadline, become error results; a cancelled permission request still stops work. | - -Selecting a pack resets its behavior fields; fields explicitly supplied in the same patch override -that pack. Inspect the resulting committed policy rather than maintaining a second mutable copy. -Steps and tool restrictions remain session configuration. Tool restrictions affect both the tools -advertised to the model and the returned calls that can be admitted. - -`steps` bounds model requests in a turn, including replacements, handoffs, and continuations after -tools. It is not a token/cost budget or wall-clock limit. Use optional `completionTimeoutMs` and -`toolTimeoutMs` for operation deadlines; null disables them. A deadline does not authorize a retry. -Permission waiting remains untimed. - -## Choose what history the model sees - -`history@1` includes completed history. `context-only@1` omits completed turns while retaining -configured/session instructions, replacement context, and the current turn's messages. Use the -latter only when your application deliberately supplies sufficient context: the journal can retain a -fact that the model does not see. - -Handoff projection is separate. `handoff-slim@1` gives the successor the latest user instruction and -handoff message; `handoff-history@1` carries history. Slim handoff reduces input but requires the -handoff to contain what the successor needs. Neither setting generates a summary. On a handoff -step the projection receives the packet plus the messages added since as `input.handoff`, and -`project` decides what to send: `history@1` sends that packet; `context-only@1` ignores it and -sends the replacement context and the current turn's messages, as on any other step. - -Custom projectors must be pure and preserve correlated tool exchanges and desired attachment refs. -They may select/rearrange valid context, but cannot fetch files or call another model. -`projectPolicy` then rewrites, in every role, each blob part the target's media capability marks -`unsupported` or `unknown` as pointer text (`projectMediaPointers`, `agent/prompt.ts`), so a custom -projector keeps blob parts as they are. Invalid projected calls fail the step before HTTP. -Malformed provider calls fail completion admission; neither becomes a tolerant tool result because -no tool operation was admitted. - -## Model settings and permission scope - -Select a provider binding name and declared model. Capabilities come from that model's bound profile, -not from guesses based on its name. Unsupported thinking or streaming combinations reject before -HTTP, including on configuration changes. Media capability never rejects (D3): a step whose target -cannot, or might not, read an attachment's media still runs, with that attachment projected as a -pointer instead of its bytes. Patch incompatible settings together when switching -models. A restored session whose saved selection is no longer bound is not rejected: replay ignores -live availability, and the next work first journals a reconciled policy (see -[registry adoption](../../../docs/session-runtime.md#restore-adopts-the-live-registry-and-bindings)). -See [model binding examples](../providers/README.md). - -Thinking `adaptive` means native adaptive support. Manual `budget` requires an explicit -`thinkingBudgetTokens` and a larger `maxOutputTokens`; the runtime never substitutes 1024. -Anthropic always requires an explicit output limit. Clear a manual budget with null when changing -thinking modes. Effort values are limited to the bound profile's declared -values. `off` or omission disables thinking. These settings are distinct, not approximations. - -`permissions: "ask"` requires a permission binding. Valid tool calls need explicit approval or a -previous live-session approval for that tool; invalid arguments do not request permission under -`return-error-and-continue`. A user refusing one call produces a permission-refused result for -that call and does not affect the batch's other calls; `toolFailure` never overrides a refusal, -because refusal is a user decision, not a tool error. Only a cancelled permission request stops -the batch and ends the turn. -`off` permits immediate execution after the intent receipt. Permission scope is a turn-boundary -choice. - -Under `return-error-and-continue` a failed call reaches the model as `{ error, issues?, detail? }`: -one message; for an `invalid_input` or `invalid_output` failure, each validation problem once as -`{ path, message }`; and for a thrown value that is not an Error, its fields as `detail`. The full -failure, with its ids, classification and cause, stays in the journal. - -## Version behavior, not saved credentials - -Policy/projection/handoff resolver IDs such as `history@1` name behavior supplied by the environment. -Restore does not require them: a saved ID the live environment lacks is reconciled to the value a new -session would get, and the next work journals that change (see -[registry adoption](../../../docs/session-runtime.md#restore-adopts-the-live-registry-and-bindings)). -If you change a custom resolver's meaning, give it a new ID rather than making saved sessions -silently project different requests. -The policy revision records committed configuration changes; it is independent of the journal's -single format identifier. Thinking, attachments, and permissions do not select journal versions. - -Test policy changes through a session as well as the pure resolver. The important assertion is which -request is dispatched after the receipt, and that work already in flight keeps its captured settings: - -```sh -LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun test packages/core-agent/session/policy-runtime.test.ts packages/core-agent/session/consumer-contract.test.ts -``` - -Look for `configuration.selected` and `configuration.applied` in the environment diagnostics; they -share a `selectionId`, and `configuration.applied` names the append, revision, version and changed -fields. A selected UI value is not evidence that the configuration applied. - -A continued tool failure projects both the readable `error` and structured `failure` into the model -result, including operation identity and the original cause. This lets the model distinguish a -missing path from a provider/client error instead of seeing only a generic message. The original -failed outcome remains in the journal; continuing does not retry the operation. diff --git a/packages/core-agent/policy/index.ts b/packages/core-agent/policy/index.ts deleted file mode 100644 index 3e2369ea..00000000 --- a/packages/core-agent/policy/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./policy.ts"; diff --git a/packages/core-agent/policy/policy.test.ts b/packages/core-agent/policy/policy.test.ts deleted file mode 100644 index 665cb412..00000000 --- a/packages/core-agent/policy/policy.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { BlobRefSchema, hashBlob, MediaKindSchema } from "../agent/content.ts"; -import { - ActorIdSchema, - AgentIdSchema, - failure, - StepsSchema, - type Failure, -} from "../agent/types.ts"; -import { - builtinResolvers, - copyResolvers, - defaultPolicy, - effectiveToolResult, - initialPolicy, - patchPolicy, - projectPolicy, -} from "./policy.ts"; - -const capabilities = { agents: [["a", { tools: ["echo"] }]] as const }; -test("named pack selection resolves defaults and validates contradictory data", () => { - const initial = defaultPolicy(capabilities, 4); - expect(patchPolicy(initial, { id: "queued@1" }, capabilities)).toMatchObject({ - admission: "queue-user", - bargeIn: false, - version: 1, - }); - expect(() => patchPolicy(initial, { admission: "queue-user" }, capabilities)).toThrow("bargeIn"); - expect(() => patchPolicy(initial, { id: "missing@1" }, capabilities)).toThrow("pack"); - expect(() => patchPolicy(initial, { tools: { a: ["other"] } }, capabilities)).toThrow( - "capabilities", - ); -}); -test("invalid custom projections cannot bypass provider tool correlation", () => { - const resolvers = copyResolvers({ - ...builtinResolvers, - projections: new Map([ - ["bad@1", () => [{ role: "tool", content: "orphan", tool_call_id: "missing" }]], - ]), - }); - const policy = initialPolicy(capabilities, 1, { project: "bad@1" }, resolvers); - expect(() => - projectPolicy( - { - agent: { model: "m", tools: [] }, - log: [], - turn: { - id: ActorIdSchema.parse("turn"), - agent: AgentIdSchema.parse("a"), - steps: StepsSchema.parse(1), - generation: 1, - messages: [], - view: { kind: "history" }, - }, - }, - [], - policy, - resolvers, - ), - ).toThrow("orphan"); -}); - -test("a custom projection pack still gets target-aware pointer rewriting", () => { - const bytes = new Uint8Array([1, 2, 3]); - const ref = BlobRefSchema.parse({ id: hashBlob(bytes), media: "image/png", bytes: bytes.length }); - const resolvers = copyResolvers({ - ...builtinResolvers, - projections: new Map([ - [ - "custom@1", - () => [{ role: "user" as const, content: "", parts: [{ type: "blob" as const, ref }] }], - ], - ]), - }); - const policy = initialPolicy(capabilities, 1, { project: "custom@1" }, resolvers); - const media = Object.fromEntries( - MediaKindSchema.options.map((kind) => [kind, "unsupported" as const]), - ) as Record<(typeof MediaKindSchema.options)[number], "unsupported">; - const result = projectPolicy( - { - agent: { model: "m", tools: [] }, - log: [], - turn: { - id: ActorIdSchema.parse("turn"), - agent: AgentIdSchema.parse("a"), - steps: StepsSchema.parse(1), - generation: 1, - messages: [], - view: { kind: "history" }, - }, - target: { provider: "p", model: "m", media }, - }, - [], - policy, - resolvers, - ); - expect(result.pointers).toEqual([ - { media: "image/png", bytes: 3, blobId: ref.id, support: "unsupported" }, - ]); - const [message] = result.messages; - if (message?.role !== "user") throw new Error("Expected a user message"); - expect(message.content).toContain(`blob://${ref.id}.png`); - expect(message.parts).toEqual([ - { type: "text", text: expect.stringContaining(`blob://${ref.id}.png`) }, - ]); -}); - -test("a continued tool failure reaches the model as one message and each validation problem once", () => { - const continuing = { toolFailure: "return-error-and-continue" } as const; - const parsed = z.object({ entries: z.array(z.string()) }).safeParse({ entries: "x" }); - const invalid = failure(parsed.error, { - classification: "invalid_input", - phase: "validate_input", - operation: { id: "s/turn/1/call", kind: "tool", sessionId: "s", toolName: "update_plan" }, - }); - const result = effectiveToolResult({ kind: "failed", error: invalid }, continuing); - expect(result).toEqual({ - kind: "succeeded", - value: { - text: JSON.stringify({ - error: "Invalid tool arguments", - issues: [{ path: ["entries"], message: "Invalid input: expected array, received string" }], - }), - }, - }); - expect(invalid.cause).toMatchObject({ name: "ZodError", issues: [{ path: ["entries"] }] }); - const broken = failure(new Error("broken", { cause: new Error("inner") }), { - operation: { id: "s/turn/1/echo", kind: "tool", sessionId: "s", toolName: "echo" }, - }); - expect(effectiveToolResult({ kind: "failed", error: broken }, continuing)).toEqual({ - kind: "succeeded", - value: { text: '{"error":"broken"}' }, - }); - expect(effectiveToolResult({ kind: "failed", error: broken })).toEqual({ - kind: "failed", - error: broken, - }); -}); - -test("a continued tool failure says an invalid output came after the tool ran, and restates a deadline without ids", () => { - const continuing = { toolFailure: "return-error-and-continue" } as const; - const text = (error: Failure) => { - const result = effectiveToolResult({ kind: "failed", error }, continuing); - return result.kind === "succeeded" ? JSON.parse(result.value.text) : undefined; - }; - const parsed = z.string().safeParse(5); - expect(text(failure(parsed.error, { classification: "invalid_output" }))).toEqual({ - error: "The tool ran, but its output was invalid", - issues: [{ path: [], message: "Invalid input: expected string, received number" }], - }); - expect( - text({ - message: "tool operation s/turn/1/2/echo exceeded its 20 ms deadline", - classification: "timeout", - timeoutMs: 20, - }), - ).toEqual({ error: "The tool exceeded its 20 ms deadline and was stopped" }); - expect(text(failure({ code: 7, reason: "gone", cause: { deep: true } }))).toEqual({ - error: "Unknown failure", - detail: { code: 7, reason: "gone" }, - }); -}); diff --git a/packages/core-agent/policy/policy.ts b/packages/core-agent/policy/policy.ts deleted file mode 100644 index 3bd138e8..00000000 --- a/packages/core-agent/policy/policy.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { z } from "zod"; - -import { ChatMessageSchema, type ChatMessage } from "../agent/agent.ts"; -import { - parseSessionContext, - projectConversationPrompt, - projectMediaPointers, - type PromptInput, -} from "../agent/prompt.ts"; -import type { ToolRunResult } from "../agent/tool-batch.ts"; -import { - StepsSchema, - ToolNameSchema, - type AgentMessage, - type Failure, - type Result, -} from "../agent/types.ts"; -import { freeze } from "../fsm/fsm.ts"; -import { - ProviderSettingsSchema, - validateThinking, - type ThinkingCapability, -} from "../providers/types.ts"; - -export const PolicyVersionSchema = z.number().int().nonnegative().brand<"PolicyVersion">(); - -const PolicyObjectSchema = z.strictObject({ - id: z.string().regex(/^.+@\d+$/), - version: PolicyVersionSchema, - ...ProviderSettingsSchema.unwrap().partial().shape, - model: z.string().min(1).optional(), - completionTimeoutMs: z.number().int().positive().max(2147483647).nullable().optional(), - toolTimeoutMs: z.number().int().positive().max(2147483647).nullable().optional(), - steps: StepsSchema, - admission: z.enum(["reject-during-tools", "abort-tools-on-user", "queue-user"]), - bargeIn: z.boolean(), - permissions: z.enum(["off", "ask"]).optional(), - toolFailure: z.enum(["fail-turn", "return-error-and-continue"]), - project: z.string().regex(/^.+@\d+$/), - handoff: z.string().regex(/^.+@\d+$/), - tools: z.record(z.string().min(1), z.array(ToolNameSchema).readonly()).readonly(), -}); - -export const PolicySchema = PolicyObjectSchema.refine( - (policy) => policy.admission !== "queue-user" || !policy.bargeIn, - "queue-user requires bargeIn=false", -).readonly(); - -export type Policy = z.infer; - -export const PolicyPatchSchema = PolicyObjectSchema.omit({ version: true }) - .partial() - .strict() - .refine( - (patch) => Object.values(patch).every((value) => value !== undefined), - "Omit undefined policy fields", - ); - -export type PolicyPatch = z.input; - -export type Capabilities = Readonly<{ - agents: readonly (readonly [string, Readonly<{ tools: readonly string[] }>])[]; -}>; - -export type Projection = (input: PromptInput) => readonly ChatMessage[]; - -export type HandoffProjection = ( - input: PromptInput & { from: string; to: string }, -) => readonly AgentMessage[]; - -export type PolicyPack = Readonly< - Pick & - Partial> ->; - -export type PolicyResolvers = Readonly<{ - permissionRequests?: boolean; - validateSelection?: ( - provider: string, - model: string | undefined, - thinking: Policy["thinking"], - stream?: boolean, - thinkingBudgetTokens?: number | null, - maxOutputTokens?: number, - ) => void; - providerStreams?: ReadonlyMap; - providerCapabilities?: ReadonlyMap; - providerIds?: ReadonlySet; - packs?: ReadonlyMap; - projections: ReadonlyMap; - handoffs: ReadonlyMap; -}>; - -const history: Projection = (input) => - projectConversationPrompt({ ...input, agent: { ...input.agent, systemPrompt: undefined } }); - -const defaultPack: PolicyPack = { - admission: "reject-during-tools", - bargeIn: true, - toolFailure: "fail-turn", - project: "history@1", - handoff: "handoff-slim@1", -}; - -const packs = new Map([ - ["default@1", defaultPack], - ["queued@1", { ...defaultPack, admission: "queue-user", bargeIn: false }], - ["strict@1", { ...defaultPack, bargeIn: false }], - ["tolerant@1", { ...defaultPack, toolFailure: "return-error-and-continue" }], -]); - -export const builtinResolvers: PolicyResolvers = { - packs, - projections: new Map([ - ["history@1", history], - [ - "context-only@1", - (input) => - history({ - ...input, - log: [], - handoff: undefined, - turn: { ...input.turn, view: { kind: "history" } }, - }), - ], - ]), - handoffs: new Map([ - [ - "handoff-slim@1", - (input) => - [ - input.turn.messages.findLast((message) => message.role === "user"), - input.turn.messages.at(-1), - ].filter((message): message is AgentMessage => message !== undefined), - ], - [ - "handoff-history@1", - (input) => [ - ...(input.context ?? []), - ...input.log.flatMap((record) => record.messages), - ...input.turn.messages, - ], - ], - ]), -}; - -export function copyResolvers(resolvers: PolicyResolvers = builtinResolvers): PolicyResolvers { - return { - permissionRequests: resolvers.permissionRequests, - validateSelection: resolvers.validateSelection, - providerStreams: resolvers.providerStreams ? new Map(resolvers.providerStreams) : undefined, - providerCapabilities: resolvers.providerCapabilities - ? new Map( - [...resolvers.providerCapabilities].map(([id, value]) => [ - id, - freeze(structuredClone(value)), - ]), - ) - : undefined, - providerIds: resolvers.providerIds ? new Set(resolvers.providerIds) : undefined, - projections: new Map(resolvers.projections), - handoffs: new Map(resolvers.handoffs), - packs: new Map( - [...(resolvers.packs ?? packs)].map(([id, pack]) => [id, freeze(structuredClone(pack))]), - ), - }; -} - -export function defaultPolicy(capabilities: Capabilities, steps: number): Policy { - return PolicySchema.parse({ - id: "default@1", - version: 0, - steps, - admission: "reject-during-tools", - bargeIn: true, - toolFailure: "fail-turn", - project: "history@1", - handoff: "handoff-slim@1", - tools: Object.fromEntries(capabilities.agents.map(([id, agent]) => [id, agent.tools])), - }); -} - -/** Policy fields whose validity depends on live bindings rather than on the policy's structure. */ -export const bindingPolicyFields = [ - "provider", - "model", - "thinking", - "thinkingBudgetTokens", - "stream", - "maxOutputTokens", - "permissions", -] as const satisfies readonly (keyof Policy)[]; - -/** Policy fields naming a versioned resolver the environment supplies: pack, projection, handoff. */ -export const resolverPolicyFields = [ - "id", - "project", - "handoff", -] as const satisfies readonly (keyof Policy)[]; - -/** The resolver fields of `policy` whose pack, projection or handoff `resolvers` do not supply. */ -export function unresolvedPolicyFields(policy: Policy, resolvers: PolicyResolvers) { - const supplied = { - id: (resolvers.packs ?? packs).has(policy.id), - project: resolvers.projections.has(policy.project), - handoff: resolvers.handoffs.has(policy.handoff), - }; - return resolverPolicyFields.filter((field) => !supplied[field]); -} - -/** Live-environment checks: the permission port, provider, model and profile settings are bound. */ -function validateBindings(policy: Policy, resolvers: PolicyResolvers) { - if (policy.permissions === "ask" && !resolvers.permissionRequests) - throw new Error("Missing permission request binding"); - if (policy.provider && !resolvers.providerIds?.has(policy.provider)) - throw new Error( - `Missing versioned provider binding: requested ${policy.provider}; available: ${[...(resolvers.providerIds ?? [])].sort().join(", ") || "none"}`, - ); - if (policy.provider && resolvers.validateSelection) - resolvers.validateSelection( - policy.provider, - policy.model, - policy.thinking, - policy.stream, - policy.thinkingBudgetTokens, - policy.maxOutputTokens, - ); - if (policy.provider && !resolvers.validateSelection && resolvers.providerCapabilities) { - const capability = resolvers.providerCapabilities.get(policy.provider); - if (!capability) throw new Error("Missing provider capabilities"); - validateThinking( - policy.thinking, - capability, - policy.thinkingBudgetTokens, - policy.maxOutputTokens, - ); - } - if ( - !resolvers.validateSelection && - policy.stream && - (!policy.provider || !resolvers.providerStreams?.get(policy.provider)) - ) - throw new Error("Unsupported streaming setting"); -} - -export function validatePolicy( - raw: unknown, - capabilities: Capabilities, - resolvers: PolicyResolvers = builtinResolvers, -): Policy { - const policy = PolicySchema.parse(raw); - if ( - !policy.provider && - [ - policy.model, - policy.thinking, - policy.stream, - policy.maxOutputTokens, - policy.thinkingBudgetTokens, - ].some((value) => value !== undefined) - ) - throw new Error("Provider settings require a provider id"); - if ( - policy.provider && - capabilities.agents.some(([, agent]) => agent.tools.includes("handoff_to")) - ) - throw new Error("Reserved handoff tool name"); - validateBindings(policy, resolvers); - const agents = new Map(capabilities.agents); - if ( - Object.keys(policy.tools).length !== agents.size || - Object.entries(policy.tools).some( - ([id, tools]) => - !agents.has(id) || - new Set(tools).size !== tools.length || - tools.some((name) => !agents.get(id)!.tools.includes(name)), - ) - ) - throw new Error("Policy tool permissions exceed host capabilities"); - const unresolved = unresolvedPolicyFields(policy, resolvers); - if (unresolved.includes("id")) throw new Error("Missing versioned policy pack"); - if (unresolved.length) throw new Error("Missing versioned policy resolver"); - return freeze(policy); -} - -export function patchPolicy( - current: Policy, - raw: PolicyPatch, - capabilities: Capabilities, - resolvers: PolicyResolvers = builtinResolvers, -): Policy { - const patch = PolicyPatchSchema.parse(raw); - const pack = - patch.id && patch.id !== current.id ? (resolvers.packs ?? packs).get(patch.id) : undefined; - return validatePolicy( - { - ...current, - ...pack, - ...patch, - tools: { ...current.tools, ...patch.tools }, - version: current.version + 1, - }, - capabilities, - resolvers, - ); -} - -/** - * The input the policy's projection pack renders. On a handoff step, the policy's handoff resolver - * builds the packet from the messages up to `turn.view.at` (the predecessor's history, as it stood - * at the handoff); the packet plus the messages the successor added since become - * `input.handoff`, and the pack decides what to send. The packet is computed here, from turn state - * and the resolver, and never journaled. - */ -function projectionInput( - input: PromptInput, - policy: Policy, - resolvers: PolicyResolvers, -): PromptInput { - const view = input.turn.view; - if (view.kind !== "handoff") return input; - const handoff = resolvers.handoffs.get(policy.handoff); - if (!handoff) throw new Error("Missing versioned handoff projection"); - const packet = handoff({ - ...input, - turn: { - ...input.turn, - messages: input.turn.messages.slice(0, view.at), - view: { kind: "history" }, - }, - from: view.from, - to: input.turn.agent, - }); - return { ...input, handoff: [...packet, ...input.turn.messages.slice(view.at)] }; -} - -/** Settings of `next` that differ from `previous`, ignoring `version`; tool scopes compare per agent. */ -export function changedPolicyFields(previous: Policy, next: Policy) { - return PolicyObjectSchema.keyof().options.filter((field) => - field === "tools" - ? [...new Set([...Object.keys(previous.tools), ...Object.keys(next.tools)])].some( - (agent) => JSON.stringify(previous.tools[agent]) !== JSON.stringify(next.tools[agent]), - ) - : field !== "version" && previous[field] !== next[field], - ); -} - -export function projectPolicy( - input: PromptInput, - systemInputs: readonly string[], - policy: Policy, - resolvers: PolicyResolvers = builtinResolvers, -) { - const project = resolvers.projections.get(policy.project); - if (!project) throw new Error("Missing versioned projection"); - const projected = project(projectionInput(input, policy, resolvers)); - const { messages: pointered, pointers } = input.target - ? projectMediaPointers(projected, input.target) - : { messages: [...projected], pointers: [] }; - const messages = z - .array(ChatMessageSchema) - .parse([ - ...(input.agent.systemPrompt ? [{ role: "system", content: input.agent.systemPrompt }] : []), - ...systemInputs.map((content) => ({ role: "system", content })), - ...pointered, - ]); - parseSessionContext( - messages.map((message) => { - if (message.role === "tool") - return { - role: "tool", - text: message.content, - callId: message.tool_call_id, - ...(message.parts ? { parts: message.parts } : {}), - }; - if (message.role === "assistant" && message.tool_calls) - return { - role: "assistant", - text: message.content, - ...(message.parts ? { parts: message.parts } : {}), - calls: message.tool_calls.map((call) => { - let args: unknown; - try { - args = JSON.parse(call.function.arguments); - } catch { - throw new Error(`Invalid JSON arguments for projected tool call ${call.id}`); - } - return { id: call.id, name: call.function.name, args }; - }), - }; - return { - role: message.role, - text: message.content, - ...(message.parts ? { parts: message.parts } : {}), - }; - }), - ); - return { messages: freeze(messages), pointers: freeze(pointers) }; -} -/** - * Deterministic domain conversion. The raw failed result remains in the journal. A tool deadline - * (classification `timeout`) is converted like any other tool failure. A permission refusal always - * converts to a "permission refused" result, regardless of `toolFailure`: refusal is a user - * decision, not a tool error the policy chooses to tolerate. The projected reason is fixed - * guidance for the model, not the stored audit message. - */ -export function effectiveToolResult( - result: Result, - policy?: Pick, -): Result { - if (result.kind !== "failed") return result; - if (result.error.classification === "permission_refused") - return { - kind: "succeeded", - value: { - text: JSON.stringify({ - refused: true, - reason: - "The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.", - }), - }, - }; - return policy?.toolFailure === "return-error-and-continue" - ? { - kind: "succeeded", - value: { text: JSON.stringify(modelToolError(result.error)) }, - } - : result; -} - -/** - * The failure as the model reads it: one message, each validation problem once as - * `{ path, message }`, and for a thrown value that is not an Error its fields as `detail`. Ids, - * phase, classification and the `cause` chain stay in the journal, and a deadline's message, which - * names the operation id, is restated without it. Only an `invalid_input` or `invalid_output` - * failure trades its message (the problems pretty-printed) for a summary: a tool that fails to - * parse data it read was given valid arguments, so it keeps its own message. - */ -function modelToolError(error: Failure): { - error: string; - issues?: ToolIssue[]; - detail?: Record; -} { - if (error.classification === "timeout" && error.timeoutMs !== undefined) - return { error: `The tool exceeded its ${error.timeoutMs} ms deadline and was stopped` }; - const cause = record(error.cause); - const summary = validationSummary[error.classification ?? ""]; - const raw = cause?.issues; - if (summary !== undefined && Array.isArray(raw) && raw.length > 0) { - const issues = raw.map((value): ToolIssue => { - const issue = record(value); - return { - path: Array.isArray(issue?.path) - ? issue.path.filter((key) => typeof key === "string" || typeof key === "number") - : [], - message: typeof issue?.message === "string" ? issue.message : "Invalid value", - }; - }); - return { error: error.message === cause?.message ? summary : error.message, issues }; - } - if (cause === undefined || typeof cause.message === "string") return { error: error.message }; - const { cause: _chain, ...detail } = cause; - return { error: error.message, detail }; -} - -const validationSummary: Readonly> = { - invalid_input: "Invalid tool arguments", - // An output check runs after the tool, so its effect may already have happened. - invalid_output: "The tool ran, but its output was invalid", -}; - -function record(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -type ToolIssue = { path: (string | number)[]; message: string }; - -export function initialPolicy( - capabilities: Capabilities, - steps: number, - patch: PolicyPatch = {}, - resolvers: PolicyResolvers = builtinResolvers, -): Policy { - return validatePolicy( - { - ...patchPolicy(defaultPolicy(capabilities, steps), patch, capabilities, resolvers), - version: 0, - }, - capabilities, - resolvers, - ); -} diff --git a/packages/core-agent/prettier.config.mjs b/packages/core-agent/prettier.config.mjs deleted file mode 100644 index c8a07af3..00000000 --- a/packages/core-agent/prettier.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Formats the journal fixture baselines that `session/fixture-runner.ts` writes. The rest of the - * repository is formatted by Biome, which is told to leave the fixtures directory alone. - * - * @type {import("prettier").Config} - */ -export default { - printWidth: 100, - tabWidth: 2, - useTabs: false, - singleQuote: false, - semi: true, - trailingComma: "all", - overrides: [{ files: ["*.json"], options: { trailingComma: "none" } }], -}; diff --git a/packages/core-agent/providers/README.md b/packages/core-agent/providers/README.md deleted file mode 100644 index 36dd8f57..00000000 --- a/packages/core-agent/providers/README.md +++ /dev/null @@ -1,274 +0,0 @@ -# Model bindings and provider transport - -Use provider bindings when you want the session to make HTTP model calls. Use a custom -`CompletionPort` when your application already has a completion source. Both return through the -same admission and persistence gates; neither should execute model-requested tools itself. - -A user selects a provider, model, and settings. The environment resolves those into a wire model ID -and an encoder/decoder profile. This keeps adapter revisions out of your UI while retaining the -implementation identity needed to diagnose a bad request. Declare the models you support: core -cannot infer from a model name whether your endpoint supports adaptive thinking or images. - -## Declare the selection your application offers - -```ts -import { createSession } from "../session/index.ts"; -import { anthropicMessagesV4 } from "./index.ts"; - -const session = await createSession({ - persistence, - configuration: { - agent: "researcher", - agents: new Map([["researcher", { model: "reviewer", tools: [], successors: [] }]]), - steps: 8, - policy: { - provider: "anthropic", - model: "reviewer", - thinking: "adaptive", - maxOutputTokens: 32768, - stream: true, - }, - }, - bindings: { - providers: new Map([ - [ - "anthropic", - { - profile: anthropicMessagesV4, - models: new Map([ - ["reviewer", { wireModel: configuredModelId, profile: anthropicMessagesV4 }], - ]), - transport: { - baseUrl: "https://api.anthropic.com/v1", - headers: { "x-api-key": anthropicKey }, - capture: run.capture, - }, - }, - ], - ]), - }, -}); -await session.updatePolicy({ model: "reviewer", thinking: "off", permissions: "off" }); -``` - -Supply exactly one completion port or provider registry. A provider-bound session requires an -explicit provider policy. A model registry requires an explicit model selection and rejects unknown -names with the supported alternatives. Bindings without a model registry accept the requested wire -model directly, using their declared profile. Binding keys need not match profile IDs. Registries, -capabilities, profiles and transport settings are captured at construction; credentials and origins -are never journal data. Restore loads persisted selections as history and does not check them -against these bindings; the first new work reconciles them with the live bindings before it runs. - -## Model catalog - -`catalogProviders(env)` and `localhostProvider(options)` turn a provider/model catalog into the -declarations above, so applications offer named providers and models instead of adapter versions. -The catalog is a committed snapshot of [models.dev](https://models.dev/catalog.json) -(`models.dev.json`, source in `CATALOG_SOURCE`); it is never fetched at runtime. To refresh it, -replace the snapshot file and review the resulting model list. - -`catalogProviders` binds anthropic, openai, google and xai when one of the provider's snapshot `env` -names has a value, and lists the rest in `skipped` with the names it checked. It skips non-chat -models (realtime, image, voice and similar), sorts newest first, and picks a preferred default. -`localhostProvider` reads `GET /models` from an OpenAI-chat-compatible server -(`LOCALHOST_BASE_URL` is the conventional default). An unreachable or empty server returns -`unavailable` with a reason and HTTP status; it never throws. - -Each model carries its own profile and thinking choices; the provider does not fix them: - -| Provider | Profile | -| -------------- | ---------------------------------------------------------------------------- | -| anthropic | `anthropic-messages@4` if adaptive-capable, otherwise `anthropic-messages@3` | -| google | `google-generate@3` | -| openai | `openai-responses@3` | -| xai, localhost | `openai-chat@2` | - -An Anthropic model is adaptive-capable when the snapshot lists a thinking toggle or effort values -that include `xhigh` or `max`. Budget-only models such as Claude Sonnet 4.5 get `@3`. - -`thinking` lists the choices valid for that model: `off` (except always-on Fable/Mythos), then -`adaptive`, `budget` (with `thinkingBudgetMin`) or effort levels. `maxOutputTokens` is the -catalog's output limit. `omitThinkingWhenOff` marks effort profiles, where "off" means sending no -thinking field. Core reads no environment and performs no I/O of its own: the caller injects `env` -and, for localhost, `fetch`. Returned `headers` carry credentials; `credential` names the env var -that supplied them and is safe to log. - -## Choose settings without silently changing their meaning - -| Setting | What the binding must promise | Why rejection matters | -| ----------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| Adaptive thinking | Native adaptive capability. | A fixed token budget is a different user choice. | -| Budget thinking | Caller-selected token budget. | Send the requested budget exactly; never substitute the minimum. | -| Streaming | A supported dialect and complete-response assembler. | Visible text alone cannot establish a valid answer or tool call. | -| Media | Not a rejection setting (D3): unsupported/unknown media is projected as a pointer, never a configuration error. | A stored attachment ref does not mean its contents reached the model as sent bytes. | - -Switching model and incompatible settings should be one policy patch. Unsupported combinations -fail before HTTP, so the caller gets a configuration error instead of paying for a request the -adapter cannot represent. Binding declarations remain your responsibility: local validation does -not prove that a remote model deployment accepts them. - -## Implemented dialects - -All existing encoder/decoder implementations remain available to environment authors: - -| Profile | Thinking | Streaming | Continuations | -| ------------------------- | --------------- | --------- | ------------------------ | -| openai-chat@1 / @2 | effort | @2 | none | -| openai-responses@1 | off | no | none | -| openai-responses@2 / @3 | effort | @3 | encrypted reasoning | -| anthropic-messages@1 | off | no | none | -| anthropic-messages@2 / @3 | explicit budget | @3 | signed/redacted thinking | -| anthropic-messages@4 | native adaptive | yes | signed/redacted thinking | -| google-generate@1 | off | no | none | -| google-generate@2 / @3 | explicit budget | @3 | signed model parts | - -For manual thinking, set `thinking: "budget"`, `thinkingBudgetTokens`, and `maxOutputTokens`. -The output limit must exceed the thinking budget so an answer has room. Anthropic's manual minimum -is 1024; that is a validation floor, not a default or a useful workload recommendation. The encoder -sends the caller's number unchanged. Google manual budgets likewise use the caller's number. - -All Anthropic bindings require an explicit `maxOutputTokens`, including when thinking is off or -adaptive. There is no hidden 1024-token output cap. Adaptive sends no manual budget. A selected -model's declared budget/output maxima are validated before admission and again before HTTP. The -application must declare model-specific constraints; the adapter cannot infer them from a name. -Clear a manual budget with `thinkingBudgetTokens: null` when selecting another thinking mode. - -For example, `{ thinking: "budget", thinkingBudgetTokens: 8192, maxOutputTokens: 32768 }` requests -an 8192-token thinking budget inside a 32768-token total output ceiling. This is an explicit example, -not a claim that those values fit every task. Output exhaustion still fails with the provider stop -reason; the runtime does not accept a truncated answer or retry it automatically. - -Provider references: [Anthropic extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking) -and [Gemini thinking](https://ai.google.dev/gemini-api/docs/generate-content/thinking). - -Responses always sends store:false -and the complete projected input, never previous_response_id or a server conversation reference. -`handoff_to` is reserved for agent routing; do not register a tool with that name. Mixed handoff/tool -output is rejected because a single completion cannot both transfer control and start a local batch. - -Profiles encode/decode only. The shared transport makes exactly one HTTP attempt with the operation -AbortSignal. Base URLs include /v1 or /v1beta. Origins, keys and fetch implementations are bindings. -Profile paths are relative and cannot redirect credentials. The nonjournaled Chat convenience port -uses the same HTTP transport; its custom-server handoff field is isolated from provider profiles. - -## Port and continuation contracts - -`CompletionPortRequest`, `CompletionPortResponse` and `CompletionPort` are exported from the session -entry point. Prepared requests contain projected role/content messages, JSON argument strings, -correlated tool_call_id values and tool advertisements. `canonicalRequest` converts these into domain -messages with parsed arguments for profiles. See the executable -[completion binding example](../session/examples/completion-binding.ts) and -[consumer acceptance tests](../session/consumer-contract.test.ts). - -`CompletionPortResponse.usage` preserves response accounting through admission and the matching -journal receipt. Built-in HTTP profiles retain native usage fields and normalize known input, -output and total counters. Anthropic input includes uncached, cache-read and cache-write tokens; -OpenAI and Google input already includes cached tokens. Google output includes candidate and -thinking tokens. Missing counters stay absent; they do not become zero. HTTP provenance identifies -the selected model, wire model, profile and request IDs. Configured credentials are redacted before -this evidence enters the response or journal. - -Inspect `session.lastCompletionUsage` for the most recent admitted response carrying accounting. -Its `usage.status` is `reported` or `invalid`. Invalid provider accounting preserves the original -fields and an explanatory error, logs `provider.usage.invalid`, and does not invalidate an otherwise -usable completion. A custom port must supply this validated union; malformed port envelopes still -fail admission. Stream deltas never update this property. Failed or cancelled completions retain -transport evidence but cannot replace the last committed accounting record. - -These are per-response counts, not current context occupancy or cumulative session cost. Do not -sum them to build a context meter: repeated prompt tokens are counted again on each request. -Projecting history, changing models, or adding tool results can change the next request independently. -The runtime does not infer model capacity, pricing, or a tokenizer from an adapter name. -Counter definitions follow [Anthropic cache accounting](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) -and [Google usage metadata](https://ai.google.dev/api/generate-content#UsageMetadata). - -Some providers require opaque signed or encrypted context from a previous response. Preserve it -through `continuationPayload`; do not turn it into visible reasoning or manufacture a replacement. -The host assigns provider and completion ownership, and the session saves it with the outcome -before tools may run. Projection selects only envelopes belonging to retained assistant messages -and the selected provider. Switching providers does not send another provider's opaque payloads. - -Large payloads use session blobs rather than oversized journal fields. Fork retains the required -context; compaction drops it with prior assistant history. Restore rebuilds ownership without -calling a model or reading blobs. See [storage limits](../../../docs/session-persistence.md). - -## Streaming and evidence - -Streaming profiles use operation-local SSE assemblers. Chat requires finish_reason and [DONE]; -Anthropic requires closed blocks, stop_reason and message_stop; Google requires finishReason STOP; -Responses requires response.completed. Malformed JSON/UTF-8, truncated frames, provider errors or -missing terminal evidence fail the completion. SSE frames are capped at 16,777,216 characters. -Only the final assembled body is decoded and admitted. Deltas are best-effort display data and never -release tools, certify receipts or become a successful partial completion. - -`TransportBinding.capture` receives actual HTTP request/response bodies and completion metadata, -including endpoint, selected/wire model, profile, status, upstream request ID, stop reason, usage, -parse stage and errors. Request evidence precedes fetch; malformed JSON and partial SSE remain -available on failure. Transport excludes credential headers and redacts configured credential values. -The environment-owned [disk capture sink](../environment/provider-capture.ts) writes a unique run -manifest and per-call report linking retained bodies. Supply tool context correlation to nested -requests. `TransportBinding.requestId` can supply environment-owned unique request IDs (for -example deterministic fixture IDs); the default generates a UUID for each HTTP operation. No reconstructed traffic is presented as actual HTTP. - -Diagnostic event families include `provider.completion.*`, `provider.http.*`, `provider.stream.*`, -`child.failed`, and `child.settled`. They retain session/turn/child IDs, timing, actual provider errors and cancellation. -Terminal failures carry serializable causes, HTTP status/request ID and parsing phase. Mutable Errors -and stacks stay outside snapshots. Inspect the launcher's durable diagnostic files; deterministic -consumer runs retain diagnostics and full traffic under `.session-artifacts/consumer*/` and -`.session-artifacts/peer-review/`. WARNING/ERROR records explain failures; routine success -emits no warning. Capture storage is caller-owned and separate from bounded diagnostic logging. - -Those event families are [effects](../effects/README.md), emitted from `httpTransport` and -`bindProviders(...).complete`, not logged directly. `TransportBinding.effects` subscribes to this -binding's provider-side events (metadata only, never request/response bodies); it is always -combined with the default diagnostics subscriber above, never replacing it. Bind -`TransportBinding.capture` separately for full bodies. - -## Attachments - -`session.model.capabilities.media` is three-valued per media kind (D3): `supported` (a bound -profile declares it — the model handles it), `unsupported` (the profile cannot encode it), or -`unknown` (no confirmation either way). `mediaCapability()` (`providers/transport.ts`) does not -currently produce `unknown`: a profile's declared media is always `supported`, because the -committed models.dev snapshot carries no per-model modality data to distinguish a vetted catalog -model from an unvetted pass-through (see `docs/session-model.md`'s "Code today" note). `unknown` is -part of the type and the projection renders it exactly like `unsupported` (a pointer); a future -catalog enrichment, or a caller building its own `ProjectionTarget`, can set it without changing -the projection's contract. Attachments are stored and journaled regardless of the -current model's media capabilities; whether the current step's target can read a given kind is a -prompt-projection decision, not a provider-layer refusal. A part the target does not support, or -might not, is projected as pointer text (`agent/content.ts` `renderBlobPointer`, -`[, [, ]: blob://.]`) instead of being sent; a tool that resolves `blob://` -URIs lets the model follow it. Accepted text attachments the target supports are sent in full. The -runtime does not replace their contents with a hash placeholder. The selected model's context limit -still applies; a provider rejection remains an explicit failure. - -Every profile accepts text/plain and text/markdown. Anthropic @2–@4 additionally support user PNG, -JPEG and PDF as base64 blocks. Google profiles support user audio as native `inlineData` parts, -including WAV, MP3/MPEG, AIFF, AAC, OGG, FLAC, M4A, L16, Opus, A-law, μ-law and WebM. The MIME type -and bytes are preserved; no transcription or text stub is substituted. See Google's -[audio input documentation](https://ai.google.dev/gemini-api/docs/generate-content/audio). -A target bound to a profile that does not declare a media kind gets a pointer for it instead of a -refusal; this includes blobs in tool results, such as binary MCP content. -Attachment bytes are verified by hash/length through an operation-local resolver. UTF-8 text -is decoded in full within the 8 MiB blob limit. No implicit -summarization, path reading, PDF conversion or Files API is performed. Attachment refs and continuation -refs use the same journal format as text and permissions. - -## Verification - -```sh -bun test packages/core-agent/providers packages/core-agent/session/consumer-contract.test.ts -LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun test packages/core-agent/providers -bunx tsc --noEmit -``` - -Tests use injected responses and make no paid provider calls. Exact wire vectors, cancellation, -credential redaction, streaming EOF, model selection, restoration and nested request sizes are covered. - -Explicit provider token limits and refusals remain failed completions. Their public failure carries -`providerStop: { category: "token_limit" | "refusal", reason }`, where `reason` is the provider's -terminal value. Consumers can report the limit or refusal without parsing English error messages. -This field survives streamed failures and HTTP error translation. Partial answers and tool calls are -not admitted, and the runtime does not retry. Unknown stop reasons and truncated streams remain -ordinary failures; they must not be labelled as a known token limit without provider evidence. diff --git a/packages/core-agent/providers/anthropic-adaptive.test.ts b/packages/core-agent/providers/anthropic-adaptive.test.ts deleted file mode 100644 index 0ef117f7..00000000 --- a/packages/core-agent/providers/anthropic-adaptive.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { - anthropicMessagesV3, - anthropicMessagesV4, - bindProviders, - CompletionRequestSchema, -} from "./index.ts"; -import { streamResponse, streamVector } from "./testing/stream-vectors.ts"; - -const request = CompletionRequestSchema.parse({ - maxOutputTokens: 16384, - model: "claude-sonnet-5", - messages: [], - tools: [], - successors: [], - thinking: "adaptive", -}); -test("native adaptive thinking has its own wire version, with no manual budget requirement", () => { - for (const stream of [false, true]) { - expect(anthropicMessagesV4.encode({ ...request, stream }).body).toMatchObject({ - thinking: { type: "adaptive" }, - max_tokens: 16384, - stream, - }); - expect(JSON.stringify(anthropicMessagesV4.encode({ ...request, stream }).body)).not.toContain( - "budget_tokens", - ); - expect( - anthropicMessagesV3.encode({ - ...request, - thinking: "budget", - thinkingBudgetTokens: 1024, - maxOutputTokens: 4096, - stream, - }).body, - ).toMatchObject({ thinking: { type: "enabled", budget_tokens: 1024 } }); - } - for (const thinking of [undefined, "off"] as const) { - expect(anthropicMessagesV4.encode({ ...request, thinking }).body).toMatchObject({ - thinking: { type: "disabled" }, - }); - } - expect(() => anthropicMessagesV3.encode({ ...request, thinking: "budget" })).toThrow( - "thinkingBudgetTokens", - ); -}); - -test("native adaptive streaming preserves signed tool continuation and exact provider ownership", async () => { - const profile = anthropicMessagesV4; - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async (_url, init) => { - expect(JSON.parse(String(init?.body))).toMatchObject({ - thinking: { type: "adaptive" }, - stream: true, - }); - return streamResponse(streamVector(anthropicMessagesV3, true)); - }) as typeof fetch, - }, - }, - ], - ]), - ); - const result = await port.complete( - PreparedModelSchema.parse({ - maxOutputTokens: 16384, - provider: profile.id, - model: request.model, - messages: [], - thinking: "adaptive", - stream: true, - }), - new AbortController().signal, - ); - expect(result.completion).toMatchObject({ kind: "tools" }); - const owner = { turnId: "turn-1", generation: 2 }; - const replay = CompletionRequestSchema.parse({ - ...request, - provider: profile.id, - messages: [{ role: "assistant" as const, text: "Hello", owner }], - continuations: [{ provider: profile.id, owner, payload: result.continuationPayload! }], - }); - expect(JSON.stringify(profile.encode(replay).body)).toContain('"signature":"signature"'); - expect(() => - profile.encode({ - ...replay, - continuations: replay.continuations!.map((entry) => ({ - ...entry, - provider: anthropicMessagesV3.id, - })), - }), - ).toThrow(); -}); diff --git a/packages/core-agent/providers/anthropic-messages-v2.ts b/packages/core-agent/providers/anthropic-messages-v2.ts deleted file mode 100644 index e53fee80..00000000 --- a/packages/core-agent/providers/anthropic-messages-v2.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { z } from "zod"; - -import type { BlobResolver } from "../agent/content.ts"; -import { ToolCallSchema, type AgentMessage } from "../agent/types.ts"; -import { anthropicStopReason } from "./anthropic-stop.ts"; -import { - advertisements, - attachmentBytes, - attachmentText, - completion, - continuationPayload, - responseBody, - systemAndMessages, -} from "./shared.ts"; -import { - matchingContinuations, - parseRequest, - validateProviderSettings, - type CompletionProfile, -} from "./types.ts"; -import { decodeUsage } from "./usage.ts"; - -const thinkingBlock = z.discriminatedUnion("type", [ - z.strictObject({ - type: z.literal("thinking"), - thinking: z.string(), - signature: z.string().min(1), - }), - z.strictObject({ type: z.literal("redacted_thinking"), data: z.string().min(1) }), -]); - -const payloadSchema = z.strictObject({ blocks: z.array(thinkingBlock).min(1) }); - -const block = z.discriminatedUnion("type", [ - ...thinkingBlock.options, - z.object({ type: z.literal("text"), text: z.string() }), - z.object({ - type: z.literal("tool_use"), - id: z.string(), - name: z.string(), - input: z.record(z.string(), z.json()), - }), -]); - -function messageBlocks(message: AgentMessage, blobs?: BlobResolver): unknown[] { - if (!message.parts) return message.text ? [{ type: "text", text: message.text }] : []; - return message.parts.map((part) => { - if (part.type === "text") return { type: "text", text: part.text }; - if ( - part.ref.media === "image/png" || - part.ref.media === "image/jpeg" || - part.ref.media === "application/pdf" - ) { - if (message.role !== "user" && message.role !== "tool") - throw new Error("Anthropic image/document attachments require a user or tool message"); - return { - type: part.ref.media === "application/pdf" ? "document" : "image", - source: { - type: "base64", - media_type: part.ref.media, - data: Buffer.from(attachmentBytes(part.ref, blobs)).toString("base64"), - }, - }; - } - return { type: "text", text: attachmentText(part.ref, blobs) }; - }); -} - -export function anthropicMessagesProfile(id: string, nativeAdaptive = false): CompletionProfile { - return { - id, - capabilities: { - thinking: nativeAdaptive ? { mode: "adaptive" } : { mode: "budget", minTokens: 1024 }, - outputTokens: { required: true }, - stream: false, - media: ["text/plain", "text/markdown", "image/png", "image/jpeg", "application/pdf"], - }, - encode(raw, blobs) { - const request = parseRequest(raw, id); - validateProviderSettings(request, this.capabilities); - const split = systemAndMessages(request, blobs); - const messages: { role: string; content: unknown[] }[] = []; - for (const message of split.messages) { - const role = message.role === "assistant" ? "assistant" : "user"; - const content: unknown[] = - message.role === "tool" - ? [ - { - type: "tool_result", - tool_use_id: message.callId, - content: message.parts ? messageBlocks(message, blobs) : message.text, - }, - ] - : [ - ...matchingContinuations([message], request.continuations ?? [], id).flatMap( - (entry) => payloadSchema.parse(continuationPayload(entry, blobs)).blocks, - ), - ...messageBlocks(message, blobs), - ...(message.role === "assistant" - ? (message.calls ?? []).map((call) => ({ - type: "tool_use", - id: call.id, - name: call.name, - input: z.record(z.string(), z.json()).parse(call.args), - })) - : []), - ]; - const previous = messages.at(-1); - if (previous?.role === role) { - const hasThinking = (parts: unknown[]) => - parts.some((part) => thinkingBlock.safeParse(part).success); - if (role === "assistant" && (hasThinking(previous.content) || hasThinking(content))) - throw new Error( - "Cannot merge adjacent assistant messages carrying thinking continuations", - ); - previous.content.push(...content); - } else messages.push({ role, content }); - } - return { - path: "/messages", - method: "POST", - headers: { "anthropic-version": "2023-06-01" }, - body: { - model: request.model, - max_tokens: request.maxOutputTokens, - system: split.system.map((text) => ({ type: "text", text })), - messages, - tools: advertisements(request).map(({ parameters, ...tool }) => ({ - ...tool, - input_schema: parameters, - })), - thinking: - request.thinking === "adaptive" || request.thinking === "budget" - ? nativeAdaptive - ? { type: "adaptive" } - : { type: "enabled", budget_tokens: request.thinkingBudgetTokens } - : { type: "disabled" }, - stream: false, - ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - }, - }; - }, - decode(res) { - const usage = decodeUsage(res.body, "anthropic"); - const raw = z.record(z.string(), z.unknown()).parse(responseBody(res)); - anthropicStopReason(raw.stop_reason, raw.usage); - const body = z - .object({ - role: z.literal("assistant"), - stop_reason: z.enum(["end_turn", "tool_use", "stop_sequence"]), - content: z.array(block).min(1), - }) - .parse(raw); - const decoded = completion( - body.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""), - body.content.flatMap((part) => - part.type === "tool_use" - ? [ToolCallSchema.parse({ id: part.id, name: part.name, args: part.input })] - : [], - ), - ); - const blocks = body.content.filter( - (part) => part.type === "thinking" || part.type === "redacted_thinking", - ); - return { - ...decoded, - ...(usage ? { usage } : {}), - ...(blocks.length ? { continuationPayload: payloadSchema.parse({ blocks }) } : {}), - }; - }, - }; -} - -export const anthropicMessagesV2 = anthropicMessagesProfile("anthropic-messages@2"); diff --git a/packages/core-agent/providers/anthropic-messages.ts b/packages/core-agent/providers/anthropic-messages.ts deleted file mode 100644 index a40fff12..00000000 --- a/packages/core-agent/providers/anthropic-messages.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { z } from "zod"; - -import { ToolCallSchema } from "../agent/types.ts"; -import { anthropicStopReason } from "./anthropic-stop.ts"; -import { - advertisements, - completion, - messageText, - responseBody, - systemAndMessages, -} from "./shared.ts"; -import { parseRequest, validateProviderSettings, type CompletionProfile } from "./types.ts"; -import { decodeUsage } from "./usage.ts"; - -const block = z.discriminatedUnion("type", [ - z.object({ type: z.literal("text"), text: z.string() }), - z.object({ - type: z.literal("tool_use"), - id: z.string(), - name: z.string(), - input: z.record(z.string(), z.json()), - }), -]); -export const anthropicMessages: CompletionProfile = { - id: "anthropic-messages@1", - capabilities: { - thinking: { mode: "off" }, - outputTokens: { required: true }, - stream: false, - media: ["text/plain", "text/markdown"], - }, - encode(raw, blobs) { - const request = parseRequest(raw, "anthropic-messages@1"); - validateProviderSettings(request, this.capabilities); - const split = systemAndMessages(request, blobs); - const messages: { role: string; content: unknown[] }[] = []; - for (const message of split.messages) { - const text = messageText(message, blobs); - const role = message.role === "assistant" ? "assistant" : "user"; - const content: unknown[] = - message.role === "tool" - ? [{ type: "tool_result", tool_use_id: message.callId, content: text }] - : [ - ...(text ? [{ type: "text", text: text }] : []), - ...(message.role === "assistant" - ? (message.calls ?? []).map((call) => ({ - type: "tool_use", - id: call.id, - name: call.name, - input: z.record(z.string(), z.json()).parse(call.args), - })) - : []), - ]; - const previous = messages.at(-1); - if (previous?.role === role) previous.content.push(...content); - else messages.push({ role, content }); - } - return { - path: "/messages", - method: "POST", - headers: { "anthropic-version": "2023-06-01" }, - body: { - model: request.model, - max_tokens: request.maxOutputTokens, - system: split.system.map((text) => ({ type: "text", text })), - messages, - tools: advertisements(request).map(({ parameters, ...tool }) => ({ - ...tool, - input_schema: parameters, - })), - thinking: { type: "disabled" }, - stream: false, - ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - }, - }; - }, - decode(res) { - const usage = decodeUsage(res.body, "anthropic"); - const raw = z.record(z.string(), z.unknown()).parse(responseBody(res)); - anthropicStopReason(raw.stop_reason, raw.usage); - const body = z - .object({ - role: z.literal("assistant"), - stop_reason: z.enum(["end_turn", "tool_use", "stop_sequence"]), - content: z.array(block).min(1), - }) - .parse(raw); - return { - ...completion( - body.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""), - body.content.flatMap((part) => - part.type === "tool_use" - ? [ToolCallSchema.parse({ id: part.id, name: part.name, args: part.input })] - : [], - ), - ), - ...(usage ? { usage } : {}), - }; - }, -}; diff --git a/packages/core-agent/providers/anthropic-stop.test.ts b/packages/core-agent/providers/anthropic-stop.test.ts deleted file mode 100644 index f6f246bd..00000000 --- a/packages/core-agent/providers/anthropic-stop.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { anthropicStopReason } from "./anthropic-stop.ts"; -import { - anthropicMessages, - anthropicMessagesV2, - anthropicMessagesV3, - anthropicMessagesV4, - bindProviders, -} from "./index.ts"; -import { streamResponse, streamVector } from "./testing/stream-vectors.ts"; - -for (const reason of ["max_tokens", "model_context_window_exceeded", "pause_turn", "refusal"]) { - test(`Anthropic ${reason} preserves scalar evidence in streamed and nonstream failures`, async () => { - for (const profile of [ - anthropicMessages, - anthropicMessagesV2, - anthropicMessagesV3, - anthropicMessagesV4, - ]) { - expect(() => - profile.decode({ - status: 200, - headers: new Headers(), - body: { - role: "assistant", - stop_reason: reason, - content: [{ type: "text", text: "PRIVATE_CONTENT" }], - usage: { output_tokens: 4096 }, - }, - }), - ).toThrow(`stop_reason=${reason} (output_tokens=4096)`); - } - let decoded = false; - const profile = { - ...anthropicMessagesV4, - decode() { - decoded = true; - throw new Error("must not decode partial output"); - }, - }; - const events = streamVector(anthropicMessagesV3).map((event) => { - const data = JSON.parse(event.data); - if (data.type === "message_delta") { - data.delta.stop_reason = reason; - data.usage = { output_tokens: 4096, private_field: "PRIVATE_CONTENT" }; - } - return { ...event, data: JSON.stringify(data) }; - }); - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => streamResponse(events)) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - PreparedModelSchema.parse({ - maxOutputTokens: 16384, - provider: profile.id, - model: "m", - messages: [], - stream: true, - }), - new AbortController().signal, - ), - ).rejects.toThrow(`stop_reason=${reason} (output_tokens=4096)`); - expect(decoded).toBe(false); - }); -} - -test("stop diagnostics exclude arbitrary content and nonnumeric usage", () => { - try { - anthropicStopReason("PRIVATE\nCONTENT", { - output_tokens: "PRIVATE_CONTENT", - input_tokens: 12, - other: "PRIVATE_CONTENT", - }); - throw new Error("expected failure"); - } catch (error) { - expect(String(error)).toContain("stop_reason=invalid_or_missing (input_tokens=12)"); - expect(String(error)).not.toContain("PRIVATE"); - } -}); diff --git a/packages/core-agent/providers/anthropic-stop.ts b/packages/core-agent/providers/anthropic-stop.ts deleted file mode 100644 index 81e2e2d5..00000000 --- a/packages/core-agent/providers/anthropic-stop.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** Keep scalar failure evidence without including provider content or arbitrary error bodies. */ -export function anthropicStopReason( - value: unknown, - usage?: unknown, -): "end_turn" | "tool_use" | "stop_sequence" { - if (value === "end_turn" || value === "tool_use" || value === "stop_sequence") return value; - const reason = - typeof value === "string" && /^[a-z_]{1,64}$/.test(value) ? value : "invalid_or_missing"; - const counts: string[] = []; - if (usage && typeof usage === "object") { - for (const key of ["input_tokens", "output_tokens"] as const) { - const count = (usage as Record)[key]; - if (typeof count === "number" && Number.isSafeInteger(count) && count >= 0) - counts.push(`${key}=${count}`); - } - } - const hint = - reason === "max_tokens" - ? " Output token limit reached; increase maxOutputTokens or narrow the request." - : reason === "model_context_window_exceeded" - ? " Model context window exhausted; compact or narrow the conversation." - : ""; - const error = new Error( - `Anthropic completion stopped: stop_reason=${reason}${counts.length ? ` (${counts.join(", ")})` : ""}.${hint} No partial completion was accepted.`, - ); - if (reason === "max_tokens" || reason === "model_context_window_exceeded" || reason === "refusal") - Object.assign(error, { - providerStop: { category: reason === "refusal" ? "refusal" : "token_limit", reason }, - }); - throw error; -} diff --git a/packages/core-agent/providers/attachments.test.ts b/packages/core-agent/providers/attachments.test.ts deleted file mode 100644 index 9bcdbc24..00000000 --- a/packages/core-agent/providers/attachments.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { BlobRefSchema, hashBlob } from "../agent/content.ts"; -import { MessageSchema } from "../agent/types.ts"; -import { - anthropicMessages, - anthropicMessagesV2, - CompletionRequestSchema, - googleGenerate, - openaiChat, - openaiResponses, -} from "./index.ts"; - -const markdown = new TextEncoder().encode("# Design\nPinned review content."); -const ref = BlobRefSchema.parse({ - id: hashBlob(markdown), - media: "text/markdown", - bytes: markdown.length, - name: "DESIGN.md", -}); -const input = CompletionRequestSchema.parse({ - maxOutputTokens: 16384, - model: "fixture-model", - messages: [ - { - role: "user", - text: "Review", - parts: [ - { type: "text", text: "Review" }, - { type: "blob", ref }, - ], - }, - ], - tools: [], - successors: [], -}); - -test("all profiles inline small markdown while retaining refs on the canonical request", () => { - const text = "Review\n# Design\nPinned review content."; - const resolver = (id: typeof ref.id) => { - expect(id).toBe(ref.id); - return markdown; - }; - expect(openaiChat.encode(input, resolver).body).toMatchObject({ - messages: [{ role: "user", content: text }], - }); - expect(openaiResponses.encode(input, resolver).body).toMatchObject({ - input: [{ role: "user", content: text }], - }); - expect(googleGenerate.encode(input, resolver).body).toMatchObject({ - contents: [{ role: "user", parts: [{ text }] }], - }); - expect(anthropicMessages.encode(input, resolver).body).toMatchObject({ - messages: [{ role: "user", content: [{ type: "text", text }] }], - }); - expect(anthropicMessagesV2.encode(input, resolver).body).toMatchObject({ - messages: [ - { - role: "user", - content: [ - { type: "text", text: "Review" }, - { type: "text", text: "# Design\nPinned review content." }, - ], - }, - ], - }); - expect(input.messages[0]).toMatchObject({ - parts: [ - { type: "text", text: "Review" }, - { type: "blob", ref }, - ], - }); -}); - -test("large text is sent in full; invalid bytes and unsupported media fail closed", () => { - const bytes = new Uint8Array(65537).fill(65); - const large = { ...ref, id: hashBlob(bytes), bytes: bytes.length }; - const request = CompletionRequestSchema.parse({ - ...input, - messages: [{ role: "user", text: "", parts: [{ type: "blob", ref: large }] }], - }); - expect(openaiChat.encode(request, () => bytes).body).toMatchObject({ - messages: [{ role: "user", content: new TextDecoder().decode(bytes) }], - }); - expect(() => openaiChat.encode(request, () => markdown)).toThrow("do not match"); - expect(() => openaiChat.encode(input)).toThrow("resolver"); - for (const media of ["image/png", "application/pdf"] as const) - expect(() => - openaiChat.encode( - CompletionRequestSchema.parse({ - ...input, - messages: [{ role: "user", text: "", parts: [{ type: "blob", ref: { ...ref, media } }] }], - }), - () => markdown, - ), - ).toThrow("media"); -}); - -for (const media of ["image/png", "image/jpeg", "application/pdf"] as const) - test(`Anthropic v2 encodes ${media} by BlobId`, () => { - const bytes = new Uint8Array([1, 2, 3]); - const image = { id: hashBlob(bytes), bytes: bytes.length, media, name: "image" }; - const request = CompletionRequestSchema.parse({ - ...input, - messages: [{ role: "user", text: "", parts: [{ type: "blob", ref: image }] }], - }); - expect( - anthropicMessagesV2.encode(request, (id) => { - expect(id).toBe(image.id); - return bytes; - }).body, - ).toMatchObject({ - messages: [ - { - role: "user", - content: [ - { - type: media === "application/pdf" ? "document" : "image", - source: { type: "base64", media_type: media, data: "AQID" }, - }, - ], - }, - ], - }); - }); - -test("D4: a tool result carrying an image blob part reaches Anthropic as tool_result content blocks", () => { - const bytes = new Uint8Array([9, 9]); - const image = { id: hashBlob(bytes), bytes: bytes.length, media: "image/png" as const }; - const request = CompletionRequestSchema.parse({ - ...input, - messages: [ - { - role: "assistant", - text: "", - calls: [{ id: "call-1", name: "look", args: {} }], - }, - { - role: "tool", - callId: "call-1", - text: "Result image", - parts: [ - { type: "text", text: "Result image" }, - { type: "blob", ref: image }, - ], - }, - ], - }); - const body = anthropicMessagesV2.encode(request, (id) => { - expect(id).toBe(image.id); - return bytes; - }).body as { messages: unknown[] }; - expect(body.messages.at(-1)).toMatchObject({ - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call-1", - content: [ - { type: "text", text: "Result image" }, - { type: "image", source: { type: "base64", media_type: "image/png", data: "CQk=" } }, - ], - }, - ], - }); -}); - -test("message text must agree with text parts, including on tool results", () => { - expect(() => - MessageSchema.parse({ - role: "user", - text: "different", - parts: [{ type: "text", text: "body" }], - }), - ).toThrow(); - expect( - MessageSchema.parse({ - role: "assistant", - text: "ab", - parts: [ - { type: "text", text: "a" }, - { type: "text", text: "b" }, - ], - }).text, - ).toBe("ab"); - expect(() => - MessageSchema.parse({ role: "tool", text: "result", callId: "c", parts: [] }), - ).toThrow(); - expect( - MessageSchema.parse({ - role: "tool", - text: "result", - callId: "c", - parts: [{ type: "text", text: "result" }], - }).parts, - ).toEqual([{ type: "text", text: "result" }]); -}); - -test("adjacent explicit text parts concatenate without added separators", () => { - const request = CompletionRequestSchema.parse({ - ...input, - messages: [ - { - role: "user", - text: "ab", - parts: [ - { type: "text", text: "a" }, - { type: "text", text: "b" }, - ], - }, - ], - }); - expect(openaiChat.encode(request).body).toMatchObject({ - messages: [{ role: "user", content: "ab" }], - }); -}); - -test("Google profiles encode audio MIME types and bytes natively in content order", async () => { - const { AUDIO_MEDIA_KINDS } = await import("../agent/content.ts"); - const { googleGenerateV2, googleGenerateV3 } = await import("./index.ts"); - const bytes = new Uint8Array([1, 2, 3]); - for (const profile of [googleGenerate, googleGenerateV2, googleGenerateV3]) { - for (const media of AUDIO_MEDIA_KINDS) { - const ref = BlobRefSchema.parse({ id: hashBlob(bytes), media, bytes: bytes.length }); - const request = CompletionRequestSchema.parse({ - provider: profile.id, - model: "audio-model", - tools: [], - successors: [], - messages: [ - { - role: "user", - text: "beforeafter", - parts: [ - { type: "text", text: "before" }, - { type: "blob", ref }, - { type: "text", text: "after" }, - ], - }, - ], - }); - const body = profile.encode(request, () => bytes).body as any; - expect(body.contents[0].parts).toEqual([ - { text: "before" }, - { inlineData: { mimeType: media, data: "AQID" } }, - { text: "after" }, - ]); - expect(profile.capabilities.media).toContain(media); - } - } -}); diff --git a/packages/core-agent/providers/catalog.test.ts b/packages/core-agent/providers/catalog.test.ts deleted file mode 100644 index 3b1216ac..00000000 --- a/packages/core-agent/providers/catalog.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { catalogProviders, localhostProvider } from "./index.ts"; - -function anthropicModel(id: string) { - const provider = catalogProviders({ ANTHROPIC_API_KEY: "sk-test" }).providers.find( - (entry) => entry.id === "anthropic", - ); - const model = provider?.models.find((entry) => entry.id === id); - if (!model) throw new Error(`catalog lacks ${id}`); - return model; -} - -test("adaptive Anthropic models bind anthropic-messages@4 with off/adaptive thinking", () => { - const model = anthropicModel("claude-sonnet-5"); - expect(model.profile.id).toBe("anthropic-messages@4"); - expect(model.thinking).toEqual(["off", "adaptive"]); - expect(model.thinkingBudgetMin).toBeUndefined(); -}); - -test("budget-only Anthropic models bind anthropic-messages@3 with an explicit budget floor", () => { - const model = anthropicModel("claude-sonnet-4-5"); - expect(model.profile.id).toBe("anthropic-messages@3"); - expect(model.thinking).toEqual(["off", "budget"]); - expect(model.thinkingBudgetMin).toBe(1024); -}); - -test("providers without a key are skipped with the env var names that were checked", () => { - const { providers, skipped } = catalogProviders({ - ANTHROPIC_API_KEY: "sk-test", - XAI_API_KEY: "", - }); - expect(providers.map((provider) => provider.id)).toEqual(["anthropic"]); - expect(providers[0]?.credential).toBe("ANTHROPIC_API_KEY"); - expect(skipped).toEqual([ - { id: "openai", checked: ["OPENAI_API_KEY"] }, - { - id: "google", - checked: ["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"], - }, - { id: "xai", checked: ["XAI_API_KEY"] }, - ]); -}); - -test("a key under a provider's later env name binds that provider", () => { - const { providers } = catalogProviders({ GEMINI_API_KEY: "gm-test" }); - const google = providers.find((provider) => provider.id === "google"); - expect(google?.credential).toBe("GEMINI_API_KEY"); - expect(google?.headers).toEqual({ "x-goog-api-key": "gm-test" }); - expect(google?.defaultModel).toBe("gemini-2.5-flash"); - expect(google?.models.every((model) => model.profile.id === "google-generate@3")).toBe(true); -}); - -test("localhost maps the server's model list onto the chat profile", async () => { - const requested: string[] = []; - const result = await localhostProvider({ - baseUrl: "http://local.test/v1", - fetch: (async (input: string | URL | Request) => { - requested.push(String(input)); - return Response.json({ - data: [{ id: "ignored-when-models-present" }], - models: [ - { - slug: "mlx-community/Qwen3.5-9B-MLX-4bit", - display_name: "Qwen 3.5 9B", - supported_reasoning_levels: [ - { effort: "none" }, - { effort: "low" }, - { effort: "medium" }, - { effort: "high" }, - ], - }, - { slug: "hidden", supported_in_api: false }, - ], - }); - }) as typeof fetch, - }); - expect(requested).toEqual(["http://local.test/v1/models"]); - if (result.kind !== "available") throw new Error(result.reason); - expect(result.provider).toMatchObject({ - id: "localhost", - baseUrl: "http://local.test/v1", - headers: {}, - defaultModel: "mlx-community/Qwen3.5-9B-MLX-4bit", - }); - expect(result.provider.credential).toBeUndefined(); - expect(result.provider.models).toHaveLength(1); - expect(result.provider.models[0]).toMatchObject({ - id: "mlx-community/Qwen3.5-9B-MLX-4bit", - label: "Qwen 3.5 9B", - wireModel: "mlx-community/Qwen3.5-9B-MLX-4bit", - thinking: ["off", "low", "medium", "high"], - omitThinkingWhenOff: true, - }); - expect(result.provider.models[0]?.profile.id).toBe("openai-chat@2"); -}); - -test("localhost reports a non-2xx answer as unavailable with its status", async () => { - const result = await localhostProvider({ - baseUrl: "http://local.test/v1", - fetch: (async () => new Response("down", { status: 503 })) as unknown as typeof fetch, - }); - expect(result).toEqual({ - kind: "unavailable", - baseUrl: "http://local.test/v1", - reason: "GET /models answered HTTP 503", - status: 503, - }); -}); - -test("localhost reports a network error as unavailable without throwing", async () => { - const result = await localhostProvider({ - baseUrl: "http://local.test/v1", - fetch: (async () => { - throw new TypeError("connection refused"); - }) as unknown as typeof fetch, - }); - expect(result).toEqual({ - kind: "unavailable", - baseUrl: "http://local.test/v1", - reason: "connection refused", - }); -}); diff --git a/packages/core-agent/providers/catalog.ts b/packages/core-agent/providers/catalog.ts deleted file mode 100644 index 2706eb29..00000000 --- a/packages/core-agent/providers/catalog.ts +++ /dev/null @@ -1,294 +0,0 @@ -import snapshot from "./models.dev.json" with { type: "json" }; -import { - anthropicMessagesV3, - anthropicMessagesV4, - googleGenerateV3, - openaiChatV2, - openaiResponsesV3, -} from "./streaming-profiles.ts"; -import type { CompletionProfile } from "./types.ts"; - -type ReasoningOption = { - type?: string; - values?: string[]; - min?: number; - max?: number; -}; - -type CatalogModelRecord = { - id: string; - name: string; - release_date?: string; - reasoning?: boolean; - reasoning_options?: ReasoningOption[]; - tool_call?: boolean; - modalities?: { output?: string[] }; - limit?: { output?: number }; -}; - -type CatalogProviderRecord = { - id: string; - name: string; - env?: string[]; - models: CatalogModelRecord[] | Record; -}; - -type Snapshot = { source: string; providers: Record }; - -const SNAPSHOT = snapshot as Snapshot; - -/** Where the committed snapshot came from. The catalog is never fetched at runtime. */ -export const CATALOG_SOURCE: string = SNAPSHOT.source; - -/** Default origin for the local OpenAI-chat-compatible server. */ -export const LOCALHOST_BASE_URL = "http://localhost:8000/v1"; - -export type CatalogModel = Readonly<{ - /** Application model id; this is `policy.model`. */ - id: string; - label: string; - wireModel: string; - profile: CompletionProfile; - /** Display order: "off" | "adaptive" | "budget" | effort levels. */ - thinking: readonly string[]; - maxOutputTokens?: number; - thinkingBudgetMin?: number; - omitThinkingWhenOff: boolean; -}>; - -export type CatalogProvider = Readonly<{ - /** Application provider id; this is `policy.provider`. */ - id: string; - label: string; - defaultModel: string; - baseUrl: string; - /** Carries credentials. Never log. */ - headers: Readonly>; - /** Name of the env var that supplied the key, never its value. */ - credential?: string; - models: readonly CatalogModel[]; -}>; - -export type LocalhostResult = - | { kind: "available"; provider: CatalogProvider } - | { kind: "unavailable"; baseUrl: string; reason: string; status?: number }; - -const EFFORT: Record = { low: true, medium: true, high: true }; - -const KEYED = ["anthropic", "openai", "google", "xai"] as const; - -const TRANSPORTS: Record< - (typeof KEYED)[number], - { baseUrl: string; header: (key: string) => Record } -> = { - anthropic: { - baseUrl: "https://api.anthropic.com/v1", - header: (key) => ({ "x-api-key": key }), - }, - openai: { - baseUrl: "https://api.openai.com/v1", - header: (key) => ({ Authorization: `Bearer ${key}` }), - }, - google: { - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - header: (key) => ({ "x-goog-api-key": key }), - }, - xai: { - baseUrl: "https://api.x.ai/v1", - header: (key) => ({ Authorization: `Bearer ${key}` }), - }, -}; - -const PREFERRED: Record = { - anthropic: "claude-sonnet-4-6", - openai: "gpt-5.4", - google: "gemini-2.5-flash", - xai: "grok-4.5", -}; - -const SKIP = - /realtime|computer-use|transcribe|live-translate|deep-research|image|voice|tts|stt|omni-flash/i; - -function chatModels(models: CatalogProviderRecord["models"]) { - return (Array.isArray(models) ? models : Object.values(models)) - .filter((model) => { - if (model.tool_call === false) return false; - if (model.modalities?.output && !model.modalities.output.includes("text")) return false; - if (model.limit?.output === 0) return false; - return !SKIP.test(model.id); - }) - .sort( - (left, right) => - String(right.release_date ?? "").localeCompare(String(left.release_date ?? "")) || - left.name.localeCompare(right.name), - ); -} - -function option(model: CatalogModelRecord, type: string) { - return model.reasoning_options?.find((entry) => entry.type === type); -} - -function adaptive(model: CatalogModelRecord) { - const effort = option(model, "effort"); - return ( - Boolean(option(model, "toggle")) || - Boolean(effort?.values?.some((value) => value === "max" || value === "xhigh")) - ); -} - -function profileFor(providerId: string, model: CatalogModelRecord) { - if (providerId === "anthropic") { - if (adaptive(model)) return anthropicMessagesV4; - return anthropicMessagesV3; - } - if (providerId === "google") return googleGenerateV3; - if (providerId === "xai" || providerId === "localhost") return openaiChatV2; - return openaiResponsesV3; -} - -function thinkingFor(providerId: string, model: CatalogModelRecord, profile: CompletionProfile) { - const values: string[] = []; - const effort = option(model, "effort"); - const budget = option(model, "budget_tokens"); - const mode = profile.capabilities.thinking.mode; - const alwaysOn = providerId === "anthropic" && /fable|mythos/i.test(model.id); - if (!alwaysOn) values.push("off"); - if (providerId === "anthropic" && mode === "adaptive") values.push("adaptive"); - if (mode === "budget" && budget) values.push("budget"); - if (mode === "effort") { - const allowed = effort?.values?.filter((value) => EFFORT[value]) ?? []; - if (allowed.length) values.push(...allowed); - else if (model.reasoning && !effort && !budget && providerId === "xai") - values.push("low", "medium", "high"); - } - return values.length ? values : ["off"]; -} - -function wire( - provider: CatalogProviderRecord, - transport: { baseUrl: string; headers: Record; credential?: string }, -): CatalogProvider | undefined { - const models = chatModels(provider.models).map((model): CatalogModel => { - const profile = profileFor(provider.id, model); - const budget = option(model, "budget_tokens"); - return { - id: model.id, - label: model.name, - wireModel: model.id, - profile, - thinking: thinkingFor(provider.id, model, profile), - ...(model.limit?.output ? { maxOutputTokens: model.limit.output } : {}), - ...(budget?.min ? { thinkingBudgetMin: budget.min } : {}), - omitThinkingWhenOff: profile.capabilities.thinking.mode === "effort", - }; - }); - const first = models.find((entry) => entry.id === PREFERRED[provider.id]) ?? models[0]; - if (!first) return undefined; - return { - id: provider.id, - label: provider.name, - defaultModel: first.id, - baseUrl: transport.baseUrl, - headers: transport.headers, - ...(transport.credential ? { credential: transport.credential } : {}), - models, - }; -} - -/** - * Binds every snapshot provider whose credential is present in `env`. Pure: the caller supplies - * the environment, and core never reads `process.env`. - */ -export function catalogProviders(env: Readonly>): { - providers: CatalogProvider[]; - skipped: { id: string; checked: readonly string[] }[]; -} { - const providers: CatalogProvider[] = []; - const skipped: { id: string; checked: readonly string[] }[] = []; - for (const id of KEYED) { - const provider = SNAPSHOT.providers[id]; - if (!provider) continue; - const names = provider.env ?? []; - const credential = names.find((name) => env[name]); - const key = credential === undefined ? undefined : env[credential]; - if (!credential || !key) { - skipped.push({ id, checked: names }); - continue; - } - const transport = TRANSPORTS[id]; - const wired = wire(provider, { - baseUrl: transport.baseUrl, - headers: transport.header(key), - credential, - }); - if (wired) providers.push(wired); - } - return { providers, skipped }; -} - -type LocalList = { - data?: Array<{ id?: string }>; - models?: Array<{ - slug?: string; - display_name?: string; - supported_in_api?: boolean; - supported_reasoning_levels?: Array<{ effort?: string }>; - }>; -}; - -/** - * Lists models from a local OpenAI-chat-compatible server via `GET /models`. An - * unreachable or empty server is reported as `unavailable`; this never throws. - */ -export async function localhostProvider(options: { - baseUrl: string; - fetch?: typeof fetch; - timeoutMs?: number; -}): Promise { - const { baseUrl } = options; - const request = options.fetch ?? fetch; - try { - const response = await request(`${baseUrl}/models`, { - signal: AbortSignal.timeout(options.timeoutMs ?? 2000), - }); - if (!response.ok) { - return { - kind: "unavailable", - baseUrl, - reason: `GET /models answered HTTP ${response.status}`, - status: response.status, - }; - } - const body = (await response.json()) as LocalList; - const listed = (body.models ?? []) - .filter((model) => model.supported_in_api !== false && model.slug) - .map((model) => ({ - id: model.slug!, - name: model.display_name || model.slug!, - reasoning: true, - reasoning_options: [ - { - type: "effort", - values: (model.supported_reasoning_levels ?? []) - .map((level) => level.effort) - .filter((effort): effort is string => Boolean(effort)), - }, - ], - })); - const models = - listed.length > 0 - ? listed - : (body.data ?? []).flatMap((model) => - model.id ? [{ id: model.id, name: model.id }] : [], - ); - const provider = wire({ id: "localhost", name: "Localhost", models }, { baseUrl, headers: {} }); - if (!provider) return { kind: "unavailable", baseUrl, reason: "GET /models listed no models" }; - return { kind: "available", provider }; - } catch (error) { - return { - kind: "unavailable", - baseUrl, - reason: error instanceof Error ? error.message : String(error), - }; - } -} diff --git a/packages/core-agent/providers/google-generate-v2.ts b/packages/core-agent/providers/google-generate-v2.ts deleted file mode 100644 index e6603ec8..00000000 --- a/packages/core-agent/providers/google-generate-v2.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { z } from "zod"; - -import { AUDIO_MEDIA_KINDS } from "../agent/content.ts"; -import { ToolCallSchema } from "../agent/types.ts"; -import { - advertisements, - attachmentBytes, - completion, - continuationPayload, - googleMessageParts, - messageText, - responseBody, - systemAndMessages, -} from "./shared.ts"; -import { - matchingContinuations, - parseRequest, - validateProviderSettings, - type CompletionProfile, -} from "./types.ts"; -import { decodeUsage } from "./usage.ts"; - -const part = z.union([ - z.strictObject({ - text: z.string(), - thought: z.boolean().optional(), - thoughtSignature: z.string().min(1).optional(), - }), - z.strictObject({ - functionCall: z.object({ - id: z.string().optional(), - name: z.string(), - args: z.record(z.string(), z.json()).optional(), - }), - thoughtSignature: z.string().min(1).optional(), - }), -]); - -const payloadSchema = z.strictObject({ parts: z.array(part).min(1) }); - -export const googleGenerateV2: CompletionProfile = { - id: "google-generate@2", - capabilities: { - thinking: { mode: "budget", minTokens: 1 }, - stream: false, - media: ["text/plain", "text/markdown", ...AUDIO_MEDIA_KINDS], - }, - encode(raw, blobs) { - const request = parseRequest(raw, "google-generate@2"); - validateProviderSettings(request, this.capabilities); - const split = systemAndMessages(request, blobs); - const contents: { role: string; parts: unknown[] }[] = []; - const calls = new Map(); - for (const message of split.messages) { - const text = message.role === "tool" ? messageText(message, blobs) : ""; - const role = message.role === "assistant" ? "model" : "user"; - const envelope = matchingContinuations( - [message], - request.continuations ?? [], - "google-generate@2", - )[0]; - const replay = envelope - ? payloadSchema.parse(continuationPayload(envelope, blobs)).parts - : undefined; - const parts: unknown[] = []; - if (message.role === "tool") - parts.push( - { - functionResponse: { - id: message.callId, - name: calls.get(message.callId), - response: { output: text }, - }, - }, - ...(message.parts ?? []).flatMap((part) => - part.type === "blob" - ? [ - { - inlineData: { - mimeType: part.ref.media, - data: Buffer.from(attachmentBytes(part.ref, blobs)).toString("base64"), - }, - }, - ] - : [], - ), - ); - else { - if (replay) parts.push(...replay); - else parts.push(...googleMessageParts(message, blobs)); - if (message.role === "assistant") - for (const call of message.calls ?? []) { - calls.set(call.id, call.name); - if (!replay) - parts.push({ - functionCall: { - id: call.id, - name: call.name, - args: z.record(z.string(), z.json()).parse(call.args), - }, - }); - } - } - const previous = contents.at(-1); - if (previous?.role === role && role !== "model") previous.parts.push(...parts); - else contents.push({ role, parts }); - } - return { - path: `/models/${encodeURIComponent(request.model.replace(/^models\//, ""))}:generateContent`, - method: "POST", - headers: {}, - body: { - contents, - systemInstruction: { parts: split.system.map((text) => ({ text })) }, - tools: [ - { - functionDeclarations: advertisements(request).map(({ parameters, ...tool }) => ({ - ...tool, - parametersJsonSchema: parameters, - })), - }, - ], - generationConfig: { - thinkingConfig: { - thinkingBudget: request.thinking === "budget" ? request.thinkingBudgetTokens : 0, - }, - ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - ...(request.maxOutputTokens === undefined - ? {} - : { maxOutputTokens: request.maxOutputTokens }), - }, - }, - }; - }, - decode(res, request) { - const usage = decodeUsage(res.body, "google"); - const body = z - .object({ - candidates: z - .array( - z.object({ - finishReason: z.literal("STOP"), - content: z.object({ role: z.literal("model"), parts: z.array(part).min(1) }), - }), - ) - .length(1), - }) - .parse(responseBody(res)); - const parts = body.candidates[0]!.content.parts; - if ( - request?.thinking === "budget" && - parts.some((value) => "functionCall" in value && !value.thoughtSignature) - ) - throw new Error("Thinking function calls require thoughtSignature"); - const decoded = completion( - parts.flatMap((value) => ("text" in value && !value.thought ? [value.text] : [])).join(""), - parts.flatMap((value, index) => - "functionCall" in value - ? [ - ToolCallSchema.parse({ - // Stable within this response. Domain correlation scopes IDs to each batch. - id: value.functionCall.id ?? `google-call-${index}`, - name: value.functionCall.name, - args: value.functionCall.args ?? {}, - }), - ] - : [], - ), - ); - return { - ...decoded, - ...(usage ? { usage } : {}), - ...(parts.some((value) => value.thoughtSignature || ("thought" in value && value.thought)) - ? { continuationPayload: { parts } } - : {}), - }; - }, -}; diff --git a/packages/core-agent/providers/google-generate.ts b/packages/core-agent/providers/google-generate.ts deleted file mode 100644 index 910cd950..00000000 --- a/packages/core-agent/providers/google-generate.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { z } from "zod"; - -import { AUDIO_MEDIA_KINDS } from "../agent/content.ts"; -import { ToolCallSchema } from "../agent/types.ts"; -import { - advertisements, - attachmentBytes, - completion, - googleMessageParts, - messageText, - responseBody, - systemAndMessages, -} from "./shared.ts"; -import { parseRequest, validateProviderSettings, type CompletionProfile } from "./types.ts"; -import { decodeUsage } from "./usage.ts"; - -const part = z.union([ - z.strictObject({ - text: z.string(), - thought: z.literal(false).optional(), - thoughtSignature: z.never().optional(), - }), - z.strictObject({ - functionCall: z.object({ - id: z.string().optional(), - name: z.string(), - args: z.record(z.string(), z.json()).optional(), - }), - thoughtSignature: z.never().optional(), - }), -]); -export const googleGenerate: CompletionProfile = { - id: "google-generate@1", - capabilities: { - thinking: { mode: "off" }, - stream: false, - media: ["text/plain", "text/markdown", ...AUDIO_MEDIA_KINDS], - }, - encode(raw, blobs) { - const request = parseRequest(raw, "google-generate@1"); - validateProviderSettings(request, this.capabilities); - const split = systemAndMessages(request, blobs); - const contents: { role: string; parts: unknown[] }[] = []; - const calls = new Map(); - for (const message of split.messages) { - const text = message.role === "tool" ? messageText(message, blobs) : ""; - const role = message.role === "assistant" ? "model" : "user"; - const parts: unknown[] = []; - if (message.role === "tool") - parts.push( - { - functionResponse: { - id: message.callId, - name: calls.get(message.callId), - response: { output: text }, - }, - }, - ...(message.parts ?? []).flatMap((part) => - part.type === "blob" - ? [ - { - inlineData: { - mimeType: part.ref.media, - data: Buffer.from(attachmentBytes(part.ref, blobs)).toString("base64"), - }, - }, - ] - : [], - ), - ); - else { - parts.push(...googleMessageParts(message, blobs)); - if (message.role === "assistant") - for (const call of message.calls ?? []) { - calls.set(call.id, call.name); - parts.push({ - functionCall: { - id: call.id, - name: call.name, - args: z.record(z.string(), z.json()).parse(call.args), - }, - }); - } - } - const previous = contents.at(-1); - if (previous?.role === role) previous.parts.push(...parts); - else contents.push({ role, parts }); - } - return { - path: `/models/${encodeURIComponent(request.model.replace(/^models\//, ""))}:generateContent`, - method: "POST", - headers: {}, - body: { - contents, - systemInstruction: { parts: split.system.map((text) => ({ text })) }, - tools: [ - { - functionDeclarations: advertisements(request).map(({ parameters, ...tool }) => ({ - ...tool, - parametersJsonSchema: parameters, - })), - }, - ], - generationConfig: { - thinkingConfig: { thinkingBudget: 0 }, - ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - ...(request.maxOutputTokens === undefined - ? {} - : { maxOutputTokens: request.maxOutputTokens }), - }, - }, - }; - }, - decode(res) { - const usage = decodeUsage(res.body, "google"); - const body = z - .object({ - candidates: z - .array( - z.object({ - finishReason: z.literal("STOP"), - content: z.object({ role: z.literal("model"), parts: z.array(part).min(1) }), - }), - ) - .length(1), - }) - .parse(responseBody(res)); - const parts = body.candidates[0]!.content.parts; - return { - ...completion( - parts.flatMap((value) => ("text" in value ? [value.text] : [])).join(""), - parts.flatMap((value, index) => - "functionCall" in value - ? [ - ToolCallSchema.parse({ - // Stable within this response. Domain correlation scopes IDs to each batch. - id: value.functionCall.id ?? `google-call-${index}`, - name: value.functionCall.name, - args: value.functionCall.args ?? {}, - }), - ] - : [], - ), - ), - ...(usage ? { usage } : {}), - }; - }, -}; diff --git a/packages/core-agent/providers/index.ts b/packages/core-agent/providers/index.ts deleted file mode 100644 index 25d47939..00000000 --- a/packages/core-agent/providers/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export * from "./types.ts"; -export * from "./transport.ts"; -export { openaiChat } from "./openai-chat.ts"; -export { openaiResponses } from "./openai-responses.ts"; -export { anthropicMessages } from "./anthropic-messages.ts"; -export { googleGenerate } from "./google-generate.ts"; -export { anthropicMessagesV2 } from "./anthropic-messages-v2.ts"; -export { googleGenerateV2 } from "./google-generate-v2.ts"; -export { openaiResponsesV2 } from "./openai-responses-v2.ts"; -export { - openaiChatV2, - anthropicMessagesV3, - anthropicMessagesV4, - googleGenerateV3, - openaiResponsesV3, -} from "./streaming-profiles.ts"; -export { - CATALOG_SOURCE, - LOCALHOST_BASE_URL, - catalogProviders, - localhostProvider, - type CatalogModel, - type CatalogProvider, - type LocalhostResult, -} from "./catalog.ts"; - -export { CompletionUsageSchema, type CompletionUsage } from "./usage.ts"; diff --git a/packages/core-agent/providers/models.dev.json b/packages/core-agent/providers/models.dev.json deleted file mode 100644 index 6af723a1..00000000 --- a/packages/core-agent/providers/models.dev.json +++ /dev/null @@ -1,1177 +0,0 @@ -{ - "source": "https://models.dev/catalog.json", - "providers": { - "anthropic": { - "id": "anthropic", - "name": "Anthropic", - "env": ["ANTHROPIC_API_KEY"], - "models": [ - { - "id": "claude-opus-5-5", - "name": "Claude Opus 5.5", - "release_date": "2026-09-22", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-fable-5-1", - "name": "Claude Fable 5.1", - "release_date": "2026-09-01", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-opus-5", - "name": "Claude Opus 5", - "release_date": "2026-07-24", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-sonnet-5", - "name": "Claude Sonnet 5", - "release_date": "2026-06-29", - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - }, - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-fable-5", - "name": "Claude Fable 5", - "release_date": "2026-06-07", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-opus-4-8", - "name": "Claude Opus 4.8", - "release_date": "2026-05-28", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-opus-4-7", - "name": "Claude Opus 4.7", - "release_date": "2026-04-14", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "release_date": "2026-02-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "max"] - }, - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-opus-4-6", - "name": "Claude Opus 4.6", - "release_date": "2026-02-04", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "max"] - }, - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "claude-opus-4-5-20251101", - "name": "Claude Opus 4.5", - "release_date": "2025-11-24", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - }, - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 64000 - } - }, - { - "id": "claude-opus-4-5", - "name": "Claude Opus 4.5 (latest)", - "release_date": "2025-11-24", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - }, - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 64000 - } - }, - { - "id": "claude-haiku-4-5-20251001", - "name": "Claude Haiku 4.5", - "release_date": "2025-10-15", - "reasoning": true, - "reasoning_options": [ - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 64000 - } - }, - { - "id": "claude-haiku-4-5", - "name": "Claude Haiku 4.5 (latest)", - "release_date": "2025-10-15", - "reasoning": true, - "reasoning_options": [ - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 64000 - } - }, - { - "id": "claude-sonnet-4-5-20250929", - "name": "Claude Sonnet 4.5", - "release_date": "2025-09-29", - "reasoning": true, - "reasoning_options": [ - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 64000 - } - }, - { - "id": "claude-sonnet-4-5", - "name": "Claude Sonnet 4.5 (latest)", - "release_date": "2025-09-29", - "reasoning": true, - "reasoning_options": [ - { - "type": "budget_tokens", - "min": 1024 - } - ], - "limit": { - "output": 64000 - } - } - ] - }, - "openai": { - "id": "openai", - "name": "OpenAI", - "env": ["OPENAI_API_KEY"], - "models": [ - { - "id": "gpt-6-luna", - "name": "GPT-6 Luna", - "release_date": "2026-09-22", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-6-sol", - "name": "GPT-6 Sol", - "release_date": "2026-09-22", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-6-astra", - "name": "GPT-6 Astra", - "release_date": "2026-09-04", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.6", - "name": "GPT-5.6", - "release_date": "2026-07-09", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.6-luna", - "name": "GPT-5.6 Luna", - "release_date": "2026-07-09", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.6-sol", - "name": "GPT-5.6 Sol", - "release_date": "2026-07-09", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.6-terra", - "name": "GPT-5.6 Terra", - "release_date": "2026-07-09", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh", "max"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.5", - "name": "GPT-5.5", - "release_date": "2026-04-23", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.5-pro", - "name": "GPT-5.5 Pro", - "release_date": "2026-04-23", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.4-mini", - "name": "GPT-5.4 mini", - "release_date": "2026-03-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.4-nano", - "name": "GPT-5.4 nano", - "release_date": "2026-03-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.4", - "name": "GPT-5.4", - "release_date": "2026-03-05", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.4-pro", - "name": "GPT-5.4 Pro", - "release_date": "2026-03-05", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.3-chat-latest", - "name": "GPT-5.3 Chat (latest)", - "release_date": "2026-03-03", - "reasoning": false, - "limit": { - "output": 16384 - } - }, - { - "id": "gpt-5.3-codex", - "name": "GPT-5.3 Codex", - "release_date": "2026-02-05", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.3-codex-spark", - "name": "GPT-5.3 Codex Spark", - "release_date": "2026-02-05", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 32000 - } - }, - { - "id": "gpt-5.2", - "name": "GPT-5.2", - "release_date": "2025-12-11", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.2-chat-latest", - "name": "GPT-5.2 Chat", - "release_date": "2025-12-11", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["medium"] - } - ], - "limit": { - "output": 16384 - } - }, - { - "id": "gpt-5.2-pro", - "name": "GPT-5.2 Pro", - "release_date": "2025-12-11", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["medium", "high", "xhigh"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5.1", - "name": "GPT-5.1", - "release_date": "2025-11-13", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5-pro", - "name": "GPT-5 Pro", - "release_date": "2025-10-06", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["high"] - } - ], - "limit": { - "output": 272000 - } - }, - { - "id": "gpt-5", - "name": "GPT-5", - "release_date": "2025-08-07", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5-mini", - "name": "GPT-5 Mini", - "release_date": "2025-08-07", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "gpt-5-nano", - "name": "GPT-5 Nano", - "release_date": "2025-08-07", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 128000 - } - }, - { - "id": "o3-pro", - "name": "o3-pro", - "release_date": "2025-06-10", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 100000 - } - }, - { - "id": "o3", - "name": "o3", - "release_date": "2025-04-16", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 100000 - } - }, - { - "id": "o4-mini", - "name": "o4-mini", - "release_date": "2025-04-16", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 100000 - } - }, - { - "id": "gpt-4.1", - "name": "GPT-4.1", - "release_date": "2025-04-14", - "reasoning": false, - "limit": { - "output": 32768 - } - }, - { - "id": "gpt-4.1-mini", - "name": "GPT-4.1 mini", - "release_date": "2025-04-14", - "reasoning": false, - "limit": { - "output": 32768 - } - }, - { - "id": "gpt-4.1-nano", - "name": "GPT-4.1 nano", - "release_date": "2025-04-14", - "reasoning": false, - "limit": { - "output": 32768 - } - }, - { - "id": "o1-pro", - "name": "o1-pro", - "release_date": "2025-03-19", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 100000 - } - }, - { - "id": "o3-mini", - "name": "o3-mini", - "release_date": "2024-12-20", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 100000 - } - }, - { - "id": "o1", - "name": "o1", - "release_date": "2024-12-05", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 100000 - } - }, - { - "id": "gpt-4o-2024-11-20", - "name": "GPT-4o (2024-11-20)", - "release_date": "2024-11-20", - "reasoning": false, - "limit": { - "output": 16384 - } - }, - { - "id": "gpt-4o-2024-08-06", - "name": "GPT-4o (2024-08-06)", - "release_date": "2024-08-06", - "reasoning": false, - "limit": { - "output": 16384 - } - }, - { - "id": "gpt-4o-mini", - "name": "GPT-4o mini", - "release_date": "2024-07-18", - "reasoning": false, - "limit": { - "output": 16384 - } - }, - { - "id": "gpt-4o", - "name": "GPT-4o", - "release_date": "2024-05-13", - "reasoning": false, - "limit": { - "output": 16384 - } - }, - { - "id": "gpt-4o-2024-05-13", - "name": "GPT-4o (2024-05-13)", - "release_date": "2024-05-13", - "reasoning": false, - "limit": { - "output": 4096 - } - }, - { - "id": "gpt-4", - "name": "GPT-4", - "release_date": "2023-11-06", - "reasoning": false, - "limit": { - "output": 8192 - } - }, - { - "id": "gpt-4-turbo", - "name": "GPT-4 Turbo", - "release_date": "2023-11-06", - "reasoning": false, - "limit": { - "output": 4096 - } - } - ] - }, - "google": { - "id": "google", - "name": "Google", - "env": ["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"], - "models": [ - { - "id": "gemini-3.8-flash", - "name": "Gemini 3.8 Flash", - "release_date": "2026-09-02", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.7-flash", - "name": "Gemini 3.7 Flash", - "release_date": "2026-08-13", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-flash-latest", - "name": "Gemini Flash Latest", - "release_date": "2026-08-13", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.5-flash-lite", - "name": "Gemini 3.5 Flash Lite", - "release_date": "2026-07-21", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.6-flash", - "name": "Gemini 3.6 Flash", - "release_date": "2026-07-21", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-flash-lite-latest", - "name": "Gemini Flash-Lite Latest", - "release_date": "2026-07-21", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.5-flash", - "name": "Gemini 3.5 Flash", - "release_date": "2026-05-19", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.1-flash-lite", - "name": "Gemini 3.1 Flash Lite", - "release_date": "2026-05-07", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemma-4-26b-a4b-it", - "name": "Gemma 4 26B A4B IT", - "release_date": "2026-04-02", - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "limit": { - "output": 32768 - } - }, - { - "id": "gemma-4-31b-it", - "name": "Gemma 4 31B IT", - "release_date": "2026-04-02", - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - } - ], - "limit": { - "output": 32768 - } - }, - { - "id": "gemini-3.1-flash-live-preview", - "name": "Gemini 3.1 Flash Live Preview", - "release_date": "2026-03-26", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.1-flash-lite-preview", - "name": "Gemini 3.1 Flash Lite Preview", - "release_date": "2026-03-03", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.1-pro-preview", - "name": "Gemini 3.1 Pro Preview", - "release_date": "2026-02-19", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3.1-pro-preview-customtools", - "name": "Gemini 3.1 Pro Preview Custom Tools", - "release_date": "2026-02-19", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-3-flash-preview", - "name": "Gemini 3 Flash Preview", - "release_date": "2025-12-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["minimal", "low", "medium", "high"] - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-2.5-flash", - "name": "Gemini 2.5 Flash", - "release_date": "2025-06-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - }, - { - "type": "budget_tokens", - "min": 0, - "max": 24576 - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-2.5-flash-lite", - "name": "Gemini 2.5 Flash-Lite", - "release_date": "2025-06-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "toggle" - }, - { - "type": "budget_tokens", - "min": 512, - "max": 24576 - } - ], - "limit": { - "output": 65536 - } - }, - { - "id": "gemini-2.5-pro", - "name": "Gemini 2.5 Pro", - "release_date": "2025-06-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "budget_tokens", - "min": 128, - "max": 32768 - } - ], - "limit": { - "output": 65536 - } - } - ] - }, - "xai": { - "id": "xai", - "name": "xAI", - "env": ["XAI_API_KEY"], - "models": [ - { - "id": "grok-4.7", - "name": "Grok 4.7", - "release_date": "2026-09-21", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 500000 - } - }, - { - "id": "grok-4.6", - "name": "Grok 4.6", - "release_date": "2026-08-12", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high", "xhigh"] - } - ], - "limit": { - "output": 500000 - } - }, - { - "id": "grok-4.5", - "name": "Grok 4.5", - "release_date": "2026-07-08", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["low", "medium", "high"] - } - ], - "limit": { - "output": 500000 - } - }, - { - "id": "grok-4.3", - "name": "Grok 4.3", - "release_date": "2026-04-17", - "reasoning": true, - "reasoning_options": [ - { - "type": "effort", - "values": ["none", "low", "medium", "high"] - } - ], - "limit": { - "output": 30000 - } - }, - { - "id": "grok-build-0.1", - "name": "Grok Build 0.1", - "release_date": "2026-04-16", - "reasoning": true, - "reasoning_options": [], - "limit": { - "output": 256000 - } - }, - { - "id": "grok-4.20-0309-non-reasoning", - "name": "Grok 4.20 (Non-Reasoning)", - "release_date": "2026-03-09", - "reasoning": false, - "limit": { - "output": 30000 - } - }, - { - "id": "grok-4.20-0309-reasoning", - "name": "Grok 4.20 (Reasoning)", - "release_date": "2026-03-09", - "reasoning": true, - "reasoning_options": [], - "limit": { - "output": 30000 - } - } - ] - } - } -} diff --git a/packages/core-agent/providers/openai-chat.ts b/packages/core-agent/providers/openai-chat.ts deleted file mode 100644 index f86e573c..00000000 --- a/packages/core-agent/providers/openai-chat.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { z } from "zod"; - -import { ToolCallSchema } from "../agent/types.ts"; -import { advertisements, completion, jsonArguments, messageText, responseBody } from "./shared.ts"; -import { parseRequest, validateProviderSettings, type CompletionProfile } from "./types.ts"; -import { decodeUsage } from "./usage.ts"; - -const response = z.object({ - choices: z - .array( - z.object({ - finish_reason: z.enum(["stop", "tool_calls"]).optional(), - message: z.object({ - role: z.literal("assistant").optional(), - content: z.string().nullish(), - tool_calls: z - .array( - z.object({ - id: z.string().min(1), - type: z.literal("function"), - function: z.object({ name: z.string().min(1), arguments: z.string() }), - }), - ) - .optional(), - refusal: z.string().nullish(), - // Legacy custom-server extension. New profiles use the reserved tool. - handoff: z.string().optional(), - }), - }), - ) - .length(1), -}); -export const openaiChat: CompletionProfile = { - id: "openai-chat@1", - capabilities: { - thinking: { mode: "effort", values: ["none", "low", "medium", "high"] }, - stream: false, - media: ["text/plain", "text/markdown"], - }, - encode(raw, blobs) { - const request = parseRequest(raw, "openai-chat@1"); - validateProviderSettings(request, this.capabilities); - const tools = advertisements(request); - return { - path: "/chat/completions", - method: "POST", - headers: {}, - body: { - model: request.model, - messages: request.messages.map((message) => { - const text = messageText(message, blobs); - if (message.role === "tool") - return { role: "tool", content: text, tool_call_id: message.callId }; - if (message.role === "assistant" && message.calls) - return { - role: "assistant", - content: text, - tool_calls: message.calls.map((call) => ({ - id: call.id, - type: "function", - function: { name: call.name, arguments: JSON.stringify(call.args) }, - })), - }; - return { role: message.role, content: text }; - }), - ...(tools.length - ? { tools: tools.map((tool) => ({ type: "function", function: tool })) } - : {}), - ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - ...(request.maxOutputTokens === undefined - ? {} - : { max_completion_tokens: request.maxOutputTokens }), - ...(request.thinking === undefined - ? {} - : { reasoning_effort: request.thinking === "off" ? "none" : request.thinking }), - // Omit stream for compatibility with minimal OpenAI-shaped local servers. - }, - }; - }, - decode(res) { - const usage = decodeUsage(res.body, "chat"); - const message = response.parse(responseBody(res)).choices[0]!.message; - if (message.refusal) throw new Error("Provider refused completion"); - if (message.handoff) throw new Error("Use the reserved handoff tool"); - if (message.content == null && !message.tool_calls?.length) - throw new Error("No assistant message"); - return { - ...completion( - message.content ?? "", - (message.tool_calls ?? []).map((call) => - ToolCallSchema.parse({ - id: call.id, - name: call.function.name, - args: jsonArguments(call.function.arguments), - }), - ), - ), - ...(usage ? { usage } : {}), - }; - }, -}; diff --git a/packages/core-agent/providers/openai-responses-v2.ts b/packages/core-agent/providers/openai-responses-v2.ts deleted file mode 100644 index 3b6d7ae6..00000000 --- a/packages/core-agent/providers/openai-responses-v2.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { z } from "zod"; - -import { ToolCallSchema } from "../agent/types.ts"; -import { - advertisements, - completion, - continuationPayload, - jsonArguments, - messageText, - responseBody, -} from "./shared.ts"; -import { - matchingContinuations, - parseRequest, - validateProviderSettings, - type CompletionProfile, -} from "./types.ts"; -import { decodeUsage } from "./usage.ts"; - -const reasoning = z - .object({ - type: z.literal("reasoning"), - id: z.string().min(1).optional(), - encrypted_content: z.string().min(1).nullish(), - summary: z.array(z.object({ type: z.literal("summary_text"), text: z.string() })), - status: z.literal("completed").optional(), - }) - .refine( - (item) => !item.id || !!item.encrypted_content, - "Reasoning id requires encrypted_content for stateless replay", - ); -const payloadSchema = z.strictObject({ items: z.array(reasoning).min(1) }); -const output = z.union([ - reasoning, - z.object({ - type: z.literal("message"), - role: z.literal("assistant"), - status: z.literal("completed").optional(), - content: z.array(z.object({ type: z.literal("output_text"), text: z.string() })), - }), - z.object({ - type: z.literal("function_call"), - call_id: z.string().min(1), - name: z.string().min(1), - arguments: z.string(), - status: z.literal("completed").optional(), - }), -]); -export const openaiResponsesV2: CompletionProfile = { - id: "openai-responses@2", - capabilities: { - thinking: { mode: "effort", values: ["none", "low", "medium", "high"] }, - stream: false, - media: ["text/plain", "text/markdown"], - }, - encode(raw, blobs) { - const request = parseRequest(raw, "openai-responses@2"); - validateProviderSettings(request, this.capabilities); - const input: unknown[] = []; - for (const message of request.messages) { - const text = messageText(message, blobs); - for (const entry of matchingContinuations( - [message], - request.continuations ?? [], - "openai-responses@2", - )) - input.push(...payloadSchema.parse(continuationPayload(entry, blobs)).items); - if (message.role === "tool") - input.push({ type: "function_call_output", call_id: message.callId, output: text }); - else { - if (text || message.role !== "assistant" || !message.calls?.length) - input.push({ role: message.role, content: text }); - if (message.role === "assistant") - for (const call of message.calls ?? []) - input.push({ - type: "function_call", - call_id: call.id, - name: call.name, - arguments: JSON.stringify(call.args), - }); - } - } - return { - path: "/responses", - method: "POST", - headers: {}, - body: { - model: request.model, - input, - store: false, - stream: false, - tools: advertisements(request).map((tool) => ({ - type: "function", - ...tool, - strict: false, - })), - ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - ...(request.maxOutputTokens === undefined - ? {} - : { max_output_tokens: request.maxOutputTokens }), - ...(request.thinking === "off" ? {} : { include: ["reasoning.encrypted_content"] }), - ...(request.thinking === undefined - ? {} - : { reasoning: { effort: request.thinking === "off" ? "none" : request.thinking } }), - }, - }; - }, - decode(res) { - const usage = decodeUsage(res.body, "responses"); - const body = z - .object({ status: z.literal("completed"), output: z.array(output).min(1) }) - .parse(responseBody(res)); - const decoded = completion( - body.output - .flatMap((item) => (item.type === "message" ? item.content.map((part) => part.text) : [])) - .join(""), - body.output.flatMap((item) => - item.type === "function_call" - ? [ - ToolCallSchema.parse({ - id: item.call_id, - name: item.name, - args: jsonArguments(item.arguments), - }), - ] - : [], - ), - ); - const items = body.output.filter((item) => item.type === "reasoning"); - return { - ...decoded, - ...(usage ? { usage } : {}), - ...(items.length ? { continuationPayload: { items } } : {}), - }; - }, -}; diff --git a/packages/core-agent/providers/openai-responses.ts b/packages/core-agent/providers/openai-responses.ts deleted file mode 100644 index d7bb80d7..00000000 --- a/packages/core-agent/providers/openai-responses.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { z } from "zod"; - -import { ToolCallSchema } from "../agent/types.ts"; -import { advertisements, completion, jsonArguments, messageText, responseBody } from "./shared.ts"; -import { parseRequest, validateProviderSettings, type CompletionProfile } from "./types.ts"; -import { decodeUsage } from "./usage.ts"; - -const output = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("message"), - role: z.literal("assistant"), - status: z.literal("completed").optional(), - content: z.array(z.object({ type: z.literal("output_text"), text: z.string() })), - }), - z.object({ - type: z.literal("function_call"), - call_id: z.string().min(1), - name: z.string().min(1), - arguments: z.string(), - status: z.literal("completed").optional(), - }), -]); -export const openaiResponses: CompletionProfile = { - id: "openai-responses@1", - capabilities: { - thinking: { mode: "effort", values: ["none"] }, - stream: false, - media: ["text/plain", "text/markdown"], - }, - encode(raw, blobs) { - const request = parseRequest(raw, "openai-responses@1"); - validateProviderSettings(request, this.capabilities); - const input: unknown[] = []; - for (const message of request.messages) { - const text = messageText(message, blobs); - if (message.role === "tool") - input.push({ type: "function_call_output", call_id: message.callId, output: text }); - else { - if (text || message.role !== "assistant" || !message.calls?.length) - input.push({ role: message.role, content: text }); - if (message.role === "assistant") - for (const call of message.calls ?? []) - input.push({ - type: "function_call", - call_id: call.id, - name: call.name, - arguments: JSON.stringify(call.args), - }); - } - } - return { - path: "/responses", - method: "POST", - headers: {}, - body: { - model: request.model, - input, - store: false, - stream: false, - tools: advertisements(request).map((tool) => ({ - type: "function", - ...tool, - strict: false, - })), - ...(request.temperature === undefined ? {} : { temperature: request.temperature }), - ...(request.maxOutputTokens === undefined - ? {} - : { max_output_tokens: request.maxOutputTokens }), - ...(request.thinking === undefined ? {} : { reasoning: { effort: "none" } }), - }, - }; - }, - decode(res) { - const usage = decodeUsage(res.body, "responses"); - const body = z - .object({ status: z.literal("completed"), output: z.array(output).min(1) }) - .parse(responseBody(res)); - return { - ...completion( - body.output - .flatMap((item) => (item.type === "message" ? item.content.map((part) => part.text) : [])) - .join(""), - body.output.flatMap((item) => - item.type === "function_call" - ? [ - ToolCallSchema.parse({ - id: item.call_id, - name: item.name, - args: jsonArguments(item.arguments), - }), - ] - : [], - ), - ), - ...(usage ? { usage } : {}), - }; - }, -}; diff --git a/packages/core-agent/providers/parity.test.ts b/packages/core-agent/providers/parity.test.ts deleted file mode 100644 index 2e236ee9..00000000 --- a/packages/core-agent/providers/parity.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { - bindProviders, - CompletionRequestSchema, - ContinuationSchema, - googleGenerate, - googleGenerateV2, - openaiResponses, - openaiResponsesV2, -} from "./index.ts"; - -const response = (body: unknown) => ({ status: 200, headers: new Headers(), body }); - -const request = CompletionRequestSchema.parse({ - maxOutputTokens: 16384, - model: "fixture-model", - messages: [{ role: "user", text: "Go" }], - tools: [], - successors: [], -}); - -const googleBody = (parts: unknown[]) => ({ - candidates: [{ finishReason: "STOP", content: { role: "model", parts } }], -}); - -const signedParts = (round: number) => [ - { text: "private", thought: true }, - { text: `work ${round}`, thoughtSignature: `text-sig-${round}` }, - ...[0, 1].map((i) => ({ - functionCall: { id: `${round}-${i}`, name: "echo", args: { i } }, - thoughtSignature: `call-sig-${round}-${i}`, - })), -]; - -const reasoning = (round: number) => ({ - type: "reasoning", - id: `rs_${round}`, - summary: [], - encrypted_content: `encrypted-${round}`, -}); - -test("Google v2 exact budget mapping, parallel calls, and two-owner signed parts round-trip", () => { - expect(googleGenerateV2.capabilities.thinking).toEqual({ mode: "budget", minTokens: 1 }); - expect( - googleGenerateV2.encode({ thinkingBudgetTokens: 1024, ...request, thinking: "budget" }), - ).toEqual({ - path: "/models/fixture-model:generateContent", - method: "POST", - headers: {}, - body: { - contents: [{ role: "user", parts: [{ text: "Go" }] }], - systemInstruction: { parts: [] }, - tools: [{ functionDeclarations: [] }], - generationConfig: { thinkingConfig: { thinkingBudget: 1024 }, maxOutputTokens: 16384 }, - }, - }); - for (const thinking of [undefined, "off"] as const) - expect(googleGenerateV2.encode({ ...request, thinking }).body).toMatchObject({ - generationConfig: { thinkingConfig: { thinkingBudget: 0 } }, - }); - const messages: unknown[] = [...request.messages]; - const envelopes = [0, 1].map((round) => { - const parts = signedParts(round); - const decoded = googleGenerateV2.decode(response(googleBody(parts)), { - ...request, - thinking: "budget", - thinkingBudgetTokens: 1024, - }); - expect(decoded).toEqual({ - completion: { - kind: "tools", - text: `work ${round}`, - calls: [0, 1].map((i) => ({ id: `${round}-${i}`, name: "echo", args: { i } })), - }, - continuationPayload: { parts }, - }); - const owner = { turnId: "turn", generation: round }; - messages.push( - { - role: "assistant", - owner, - text: `work ${round}`, - calls: [0, 1].map((i) => ({ id: `${round}-${i}`, name: "echo", args: { i } })), - }, - ...[0, 1].map((i) => ({ role: "tool", callId: `${round}-${i}`, text: "ok" })), - ); - return ContinuationSchema.parse({ - provider: googleGenerateV2.id, - owner, - payload: decoded.continuationPayload, - }); - }); - const body = googleGenerateV2.encode( - CompletionRequestSchema.parse({ - ...request, - thinking: "budget", - thinkingBudgetTokens: 1024, - messages, - continuations: envelopes.toReversed(), - }), - ).body as { contents: { role: string; parts: unknown[] }[] }; - for (const round of [0, 1]) { - expect(body.contents[1 + 2 * round]).toEqual({ role: "model", parts: signedParts(round) }); - expect(body.contents[2 + 2 * round]?.parts).toEqual( - [0, 1].map((i) => ({ - functionResponse: { id: `${round}-${i}`, name: "echo", response: { output: "ok" } }, - })), - ); - } - const adjacent = CompletionRequestSchema.parse({ - ...request, - messages: envelopes.map((entry, i) => ({ - role: "assistant", - owner: entry.owner, - text: `work ${i}`, - })), - continuations: envelopes, - }); - expect((googleGenerateV2.encode(adjacent).body as typeof body).contents).toHaveLength(2); -}); - -test("Google v2 rejects stripped signatures with thinking on, including through transport; v1 stays strict", async () => { - const parts = signedParts(0).map(({ thoughtSignature, ...part }) => part); - expect(() => - googleGenerateV2.decode(response(googleBody(parts)), { - thinkingBudgetTokens: 1024, - ...request, - thinking: "budget", - }), - ).toThrow("thoughtSignature"); - expect( - googleGenerateV2.decode(response(googleBody(parts)), { - thinkingBudgetTokens: null, - ...request, - thinking: "off", - }).completion, - ).toMatchObject({ kind: "tools" }); - for (const parts of [ - [{ text: "private", thought: true }], - [{ text: "answer", thoughtSignature: "sig" }], - signedParts(0), - ]) - expect(() => googleGenerate.decode(response(googleBody(parts)))).toThrow(); - const port = bindProviders( - new Map([ - [ - googleGenerateV2.id, - { - profile: googleGenerateV2, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => Response.json(googleBody(parts))) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - PreparedModelSchema.parse({ - maxOutputTokens: 16384, - provider: googleGenerateV2.id, - thinking: "budget", - thinkingBudgetTokens: 1024, - model: request.model, - messages: [], - }), - new AbortController().signal, - ), - ).rejects.toThrow("thoughtSignature"); -}); - -test("Responses v2 exact stateless body and effort mapping", () => { - expect(openaiResponsesV2.encode(request)).toEqual({ - path: "/responses", - method: "POST", - headers: {}, - body: { - model: request.model, - input: [{ role: "user", content: "Go" }], - store: false, - max_output_tokens: 16384, - stream: false, - tools: [], - include: ["reasoning.encrypted_content"], - }, - }); - for (const thinking of ["off", "low", "medium", "high"] as const) { - const body = openaiResponsesV2.encode({ ...request, thinking }).body; - expect(body).toMatchObject({ - store: false, - max_output_tokens: 16384, - reasoning: { effort: thinking === "off" ? "none" : thinking }, - }); - expect(body).not.toHaveProperty("previous_response_id"); - if (thinking === "off") expect(body).not.toHaveProperty("include"); - else expect(body).toHaveProperty("include", ["reasoning.encrypted_content"]); - } -}); - -test("Responses v2 replays encrypted reasoning immediately before each owner's calls/message", () => { - const messages: unknown[] = [...request.messages]; - const envelopes = [0, 1].map((round) => { - const item = { type: "function_call", call_id: `c${round}`, name: "echo", arguments: "{}" }; - const decoded = openaiResponsesV2.decode( - response({ status: "completed", output: [reasoning(round), item] }), - ); - expect(decoded).toEqual({ - completion: { kind: "tools", text: "", calls: [{ id: `c${round}`, name: "echo", args: {} }] }, - continuationPayload: { items: [reasoning(round)] }, - }); - const owner = { turnId: "turn", generation: round }; - messages.push( - { role: "assistant", owner, text: "", calls: [{ id: `c${round}`, name: "echo", args: {} }] }, - { role: "tool", callId: `c${round}`, text: "ok" }, - ); - return ContinuationSchema.parse({ - provider: openaiResponsesV2.id, - owner, - payload: decoded.continuationPayload, - }); - }); - const body = openaiResponsesV2.encode( - CompletionRequestSchema.parse({ ...request, messages, continuations: envelopes.toReversed() }), - ).body as { input: unknown[] }; - expect(body.input).toEqual([ - { role: "user", content: "Go" }, - ...[0, 1].flatMap((round) => [ - reasoning(round), - { type: "function_call", call_id: `c${round}`, name: "echo", arguments: "{}" }, - { type: "function_call_output", call_id: `c${round}`, output: "ok" }, - ]), - ]); - expect( - openaiResponsesV2.encode( - CompletionRequestSchema.parse({ - ...request, - messages: [{ role: "assistant", owner: envelopes[0]!.owner, text: "answer" }], - continuations: [envelopes[0]], - }), - ).body, - ).toMatchObject({ input: [reasoning(0), { role: "assistant", content: "answer" }] }); - expect( - openaiResponsesV2.decode( - response({ - status: "completed", - output: [ - reasoning(0), - { type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] }, - ], - }), - ).completion, - ).toEqual({ kind: "answer", text: "done" }); -}); - -test("Responses rejects server-owned reasoning ids and v1 rejects reasoning items", () => { - for (const encrypted_content of [undefined, null, ""]) - expect(() => - openaiResponsesV2.decode( - response({ status: "completed", output: [{ ...reasoning(0), encrypted_content }] }), - ), - ).toThrow(); - expect(() => - openaiResponses.decode(response({ status: "completed", output: [reasoning(0)] })), - ).toThrow(); -}); diff --git a/packages/core-agent/providers/profiles.test.ts b/packages/core-agent/providers/profiles.test.ts deleted file mode 100644 index 2788b44b..00000000 --- a/packages/core-agent/providers/profiles.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { anthropicMessages } from "./anthropic-messages.ts"; -import { googleGenerate } from "./google-generate.ts"; -import { openaiChat } from "./openai-chat.ts"; -import { openaiResponses } from "./openai-responses.ts"; -import { profileContract, request } from "./testing/profile-contract.ts"; - -const parameters = { - type: "object", - properties: { query: { type: "string" } }, - required: ["query"], -}; -const chatCall = (id: string, query: string) => ({ - id, - type: "function", - function: { name: "lookup", arguments: JSON.stringify({ query }) }, -}); -const chat = (message: unknown, finish_reason = "stop") => ({ - choices: [{ message, finish_reason }], -}); -profileContract(openaiChat, { - answer: chat({ content: "done" }), - calls: chat( - { content: "", tool_calls: [chatCall("c1", "a"), chatCall("c2", "b")] }, - "tool_calls", - ), - handoff: chat( - { - tool_calls: [ - { id: "h", type: "function", function: { name: "handoff_to", arguments: '{"agent":"b"}' } }, - ], - }, - "tool_calls", - ), - invalid: [ - {}, - chat({ content: "partial" }, "length"), - chat({ content: null }), - chat({ refusal: "no" }), - chat({ - tool_calls: [{ id: "c", type: "function", function: { name: "lookup", arguments: "{" } }], - }), - chat({ tool_calls: [chatCall("c1", "a"), chatCall("c1", "b")] }), - ], - expectedBody: { - model: "test-model", - messages: [ - { role: "system", content: "Be concise" }, - { role: "user", content: "Find it" }, - { role: "assistant", content: "", tool_calls: [chatCall("c1", "a")] }, - { role: "tool", content: "found", tool_call_id: "c1" }, - ], - tools: [{ type: "function", function: { name: "lookup", parameters } }], - }, -}); -const responseCall = (id: string, query: string) => ({ - type: "function_call", - call_id: id, - name: "lookup", - arguments: JSON.stringify({ query }), -}); -const responses = (output: unknown[]) => ({ status: "completed", output }); -profileContract(openaiResponses, { - answer: responses([ - { type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] }, - ]), - calls: responses([responseCall("c1", "a"), responseCall("c2", "b")]), - handoff: responses([ - { type: "function_call", call_id: "h", name: "handoff_to", arguments: '{"agent":"b"}' }, - ]), - invalid: [ - {}, - { status: "incomplete", output: [] }, - responses([{ type: "web_search_call" }]), - responses([{ type: "reasoning", encrypted_content: "signature" }]), - responses([{ type: "function_call", call_id: "c1", name: "lookup", arguments: "{" }]), - ], - expectedBody: { - model: "test-model", - store: false, - stream: false, - input: [ - { role: "system", content: "Be concise" }, - { role: "user", content: "Find it" }, - responseCall("c1", "a"), - { type: "function_call_output", call_id: "c1", output: "found" }, - ], - tools: [{ type: "function", name: "lookup", parameters, strict: false }], - }, -}); -const anthropicCall = (id: string, query: string) => ({ - type: "tool_use", - id, - name: "lookup", - input: { query }, -}); -const anthropic = (content: unknown[], stop_reason = "end_turn") => ({ - role: "assistant", - content, - stop_reason, -}); -profileContract(anthropicMessages, { - answer: anthropic([{ type: "text", text: "done" }]), - calls: anthropic([anthropicCall("c1", "a"), anthropicCall("c2", "b")], "tool_use"), - handoff: anthropic( - [{ type: "tool_use", id: "h", name: "handoff_to", input: { agent: "b" } }], - "tool_use", - ), - invalid: [ - {}, - anthropic([{ type: "text", text: "partial" }], "max_tokens"), - anthropic([{ type: "thinking", thinking: "hidden", signature: "opaque" }]), - anthropic([{ type: "server_tool_use", id: "c", name: "web_search", input: {} }]), - ], - expectedBody: { - model: "test-model", - max_tokens: 1024, - system: [{ type: "text", text: "Be concise" }], - messages: [ - { role: "user", content: [{ type: "text", text: "Find it" }] }, - { role: "assistant", content: [anthropicCall("c1", "a")] }, - { role: "user", content: [{ type: "tool_result", tool_use_id: "c1", content: "found" }] }, - ], - tools: [{ name: "lookup", input_schema: parameters }], - thinking: { type: "disabled" }, - stream: false, - }, -}); -const googleCall = (id: string, query: string) => ({ - functionCall: { id, name: "lookup", args: { query } }, -}); -const google = (parts: unknown[], finishReason = "STOP") => ({ - candidates: [{ finishReason, content: { role: "model", parts } }], -}); -profileContract(googleGenerate, { - answer: google([{ text: "done" }]), - calls: google([googleCall("c1", "a"), googleCall("c2", "b")]), - handoff: google([{ functionCall: { id: "h", name: "handoff_to", args: { agent: "b" } } }]), - invalid: [ - {}, - google([{ text: "partial" }], "MAX_TOKENS"), - google([{ text: "thinking", thought: true }]), - google([{ ...googleCall("c1", "a"), thoughtSignature: "opaque" }]), - google([{ executableCode: { code: "hidden tool" } }]), - ], - expectedBody: { - contents: [ - { role: "user", parts: [{ text: "Find it" }] }, - { role: "model", parts: [googleCall("c1", "a")] }, - { - role: "user", - parts: [{ functionResponse: { id: "c1", name: "lookup", response: { output: "found" } } }], - }, - ], - systemInstruction: { parts: [{ text: "Be concise" }] }, - tools: [{ functionDeclarations: [{ name: "lookup", parametersJsonSchema: parameters }] }], - generationConfig: { thinkingConfig: { thinkingBudget: 0 } }, - }, -}); -test("all profiles reject mixed handoff/tool batches", () => { - const responsesByProfile = [ - [ - openaiChat, - chat({ - tool_calls: [ - chatCall("c1", "a"), - { - id: "h", - type: "function", - function: { name: "handoff_to", arguments: '{"agent":"b"}' }, - }, - ], - }), - ], - [ - openaiResponses, - responses([ - responseCall("c1", "a"), - { type: "function_call", call_id: "h", name: "handoff_to", arguments: '{"agent":"b"}' }, - ]), - ], - [ - anthropicMessages, - anthropic([ - anthropicCall("c1", "a"), - { type: "tool_use", id: "h", name: "handoff_to", input: { agent: "b" } }, - ]), - ], - [ - googleGenerate, - google([ - googleCall("c1", "a"), - { functionCall: { id: "h", name: "handoff_to", args: { agent: "b" } } }, - ]), - ], - ] as const; - for (const [profile, body] of responsesByProfile) - expect(() => profile.decode({ status: 200, headers: new Headers(), body })).toThrow("mixed"); -}); -test("Google allocates deterministic call IDs only when absent", () => { - const response = { - status: 200, - headers: new Headers(), - body: google([{ functionCall: { name: "lookup", args: { query: "a" } } }]), - }; - expect(googleGenerate.decode(response)).toEqual(googleGenerate.decode(response)); -}); -test("handoff advertisement is conditional and names only allowed successors", () => { - const body = JSON.stringify(openaiChat.encode({ ...request, successors: ["b"] }).body); - expect(body).toContain("handoff_to"); - expect(body).toContain('"enum":["b"]'); - expect(JSON.stringify(openaiChat.encode(request).body)).not.toContain("handoff_to"); -}); diff --git a/packages/core-agent/providers/shared.ts b/packages/core-agent/providers/shared.ts deleted file mode 100644 index 99d54312..00000000 --- a/packages/core-agent/providers/shared.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { z } from "zod"; - -import { hashBlob, type BlobRef, type BlobResolver } from "../agent/content.ts"; -import { CompletionSchema, type AgentMessage, type ToolCall } from "../agent/types.ts"; -import { rejectStoppedResponse } from "./stops.ts"; -import { - CompletionRequestSchema, - type CompletionRequest, - type Continuation, - type HttpResponse, -} from "./types.ts"; - -export const HANDOFF_TOOL = "handoff_to"; -export function advertisements(request: CompletionRequest) { - CompletionRequestSchema.parse(request); - if (request.tools.some((tool) => tool.name === HANDOFF_TOOL)) - throw new Error("Reserved handoff tool name"); - return [ - ...request.tools, - ...(request.successors.length - ? [ - { - name: HANDOFF_TOOL, - description: "Transfer the conversation to an allowed successor agent.", - parameters: { - type: "object", - properties: { agent: { type: "string", enum: [...request.successors] } }, - required: ["agent"], - additionalProperties: false, - }, - }, - ] - : []), - ]; -} -export function completion(text: string, calls: readonly ToolCall[]) { - const handoffs = calls.filter((call) => call.name === HANDOFF_TOOL); - if (handoffs.length) { - if (calls.length !== 1) throw new Error("Handoff cannot be mixed with other calls"); - const args = z.strictObject({ agent: z.string().min(1) }).parse(handoffs[0]!.args); - return { completion: CompletionSchema.parse({ kind: "handoff", text, agent: args.agent }) }; - } - return { - completion: CompletionSchema.parse( - calls.length ? { kind: "tools", text, calls } : { kind: "answer", text }, - ), - }; -} -export function jsonArguments(raw: string): unknown { - try { - return JSON.parse(raw); - } catch { - throw new Error("Invalid JSON tool arguments"); - } -} -export function responseBody(response: HttpResponse): unknown { - if (response.status < 200 || response.status >= 300) - throw new Error(`Completion HTTP failure (${response.status})`); - rejectStoppedResponse(response.body); - return response.body; -} -export function systemAndMessages(request: CompletionRequest, blobs?: BlobResolver) { - return { - system: request.messages - .filter((message) => message.role === "system") - .map((message) => messageText(message, blobs)), - messages: request.messages.filter((message) => message.role !== "system"), - }; -} - -/** Deterministic text projection; refs remain on the canonical request. */ -export function attachmentText(ref: BlobRef, blobs?: BlobResolver): string { - if (ref.media !== "text/plain" && ref.media !== "text/markdown") - throw new Error(`Cannot inline attachment media as text: ${ref.media}`); - const bytes = attachmentBytes(ref, blobs); - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); -} -export function attachmentBytes(ref: BlobRef, blobs?: BlobResolver): Uint8Array { - if (!blobs) throw new Error(`Missing blob resolver: ${ref.id}`); - const bytes = blobs(ref.id); - if (bytes.byteLength !== ref.bytes || hashBlob(bytes) !== ref.id) - throw new Error(`Attachment bytes do not match ref: ${ref.id}`); - return bytes; -} -export function messageText(message: AgentMessage, blobs?: BlobResolver): string { - if (message.role === "tool" || !message.parts) return message.text; - const segments: string[] = []; - let previousWasText = false; - for (const part of message.parts) { - if (part.type === "text" && previousWasText) segments[segments.length - 1] += part.text; - else segments.push(part.type === "text" ? part.text : attachmentText(part.ref, blobs)); - previousWasText = part.type === "text"; - } - return segments.join("\n"); -} - -/** Resolve opaque JSON only while encoding inside the completion operation. */ -export function continuationPayload(entry: Continuation, blobs?: BlobResolver): unknown { - if (!entry.payloadBlob) return entry.payload; - return z - .json() - .parse( - JSON.parse( - new TextDecoder("utf-8", { fatal: true }).decode(attachmentBytes(entry.payloadBlob, blobs)), - ), - ); -} - -/** Audio is native user content; it must never be replaced by a textual attachment stub. */ -export function googleMessageParts(message: AgentMessage, blobs?: BlobResolver): unknown[] { - const hasAudio = - message.role !== "tool" && - message.parts?.some((part) => part.type === "blob" && part.ref.media.startsWith("audio/")); - if (!hasAudio) { - const text = messageText(message, blobs); - return text ? [{ text }] : []; - } - if (message.role !== "user") throw new Error("Google audio attachments require a user message"); - return message.parts!.map((part) => { - if (part.type === "text") return { text: part.text }; - if (part.ref.media.startsWith("audio/")) - return { - inlineData: { - mimeType: part.ref.media, - data: Buffer.from(attachmentBytes(part.ref, blobs)).toString("base64"), - }, - }; - return { text: attachmentText(part.ref, blobs) }; - }); -} diff --git a/packages/core-agent/providers/stops.test.ts b/packages/core-agent/providers/stops.test.ts deleted file mode 100644 index a74fd119..00000000 --- a/packages/core-agent/providers/stops.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { failure } from "../agent/types.ts"; -import { bindProviders, googleGenerateV3, openaiChatV2, openaiResponsesV3 } from "./index.ts"; -import { streamResponse, streamVector } from "./testing/stream-vectors.ts"; - -for (const profile of [openaiChatV2, openaiResponsesV3, googleGenerateV3]) { - for (const stream of [false, true]) { - test(`${profile.id} preserves typed token limit through transport (stream=${stream})`, async () => { - const chat = profile.id === openaiChatV2.id; - const google = profile.id === googleGenerateV3.id; - const reason = chat ? "length" : google ? "MAX_TOKENS" : "max_output_tokens"; - const body = chat - ? { choices: [{ finish_reason: reason, message: { content: "partial" } }] } - : google - ? { candidates: [{ finishReason: reason, content: { parts: [{ text: "partial" }] } }] } - : { status: "incomplete", incomplete_details: { reason }, output: [] }; - let requests = 0; - const binding = bindProviders( - new Map([ - [ - "test", - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => { - requests++; - if (!stream) return Response.json(body); - const events = streamVector(profile).map((event) => { - if (event.data === "[DONE]") return event; - const data = JSON.parse(event.data); - if (chat && data.choices?.[0]?.finish_reason) - data.choices[0].finish_reason = reason; - if (google && data.candidates?.[0]?.finishReason) - data.candidates[0].finishReason = reason; - if (data.type === "response.completed") { - data.type = "response.incomplete"; - data.response.status = "incomplete"; - data.response.incomplete_details = { reason }; - return { event: data.type, data: JSON.stringify(data) }; - } - return { ...event, data: JSON.stringify(data) }; - }); - return streamResponse(events); - }) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - let observed: unknown; - try { - await binding.complete( - PreparedModelSchema.parse({ - provider: "test", - model: "m", - messages: [], - stream, - maxOutputTokens: 4096, - }), - new AbortController().signal, - ); - } catch (error) { - observed = error; - } - expect(observed).toBeInstanceOf(Error); - expect(failure(observed)).toMatchObject({ - providerStop: { category: "token_limit", reason }, - }); - expect(requests).toBe(1); - }); - } -} diff --git a/packages/core-agent/providers/stops.ts b/packages/core-agent/providers/stops.ts deleted file mode 100644 index 426a245e..00000000 --- a/packages/core-agent/providers/stops.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** Recognize explicit provider terminal signals before parsing a successful completion. */ -export function rejectStoppedResponse(body: unknown): void { - if (!body || typeof body !== "object") return; - const value = body as Record; - const record = (value: unknown): Record => - value && typeof value === "object" ? (value as Record) : {}; - - const stop = (category: "token_limit" | "refusal", reason: string): never => { - throw Object.assign( - new Error( - category === "token_limit" - ? `Provider stopped at its token limit (${reason}); adjust the output limit or reduce context as appropriate. No partial completion was accepted.` - : `Provider refused completion (${reason}). No partial completion was accepted.`, - ), - { providerStop: { category, reason } }, - ); - }; - - for (const choice of Array.isArray(value.choices) ? value.choices : []) { - const item = record(choice); - if (item.finish_reason === "length") stop("token_limit", "length"); - if (item.finish_reason === "content_filter") stop("refusal", "content_filter"); - if ( - [record(item.message).refusal, record(item.delta).refusal].some( - (value) => typeof value === "string" && value.length > 0, - ) - ) - stop("refusal", "refusal"); - } - for (const candidate of Array.isArray(value.candidates) ? value.candidates : []) { - const reason = record(candidate).finishReason; - if (reason === "MAX_TOKENS") stop("token_limit", reason); - if (reason === "SAFETY" || reason === "PROHIBITED_CONTENT" || reason === "RECITATION") - stop("refusal", reason); - } - if (value.status === "incomplete") { - const reason = record(value.incomplete_details).reason; - if (reason === "max_output_tokens") stop("token_limit", reason); - if (reason === "content_filter") stop("refusal", reason); - } - for (const output of Array.isArray(value.output) ? value.output : []) { - const content = record(output).content; - if (Array.isArray(content) && content.some((part) => record(part).type === "refusal")) - stop("refusal", "refusal"); - } -} diff --git a/packages/core-agent/providers/stream-assemblers.ts b/packages/core-agent/providers/stream-assemblers.ts deleted file mode 100644 index 78c66153..00000000 --- a/packages/core-agent/providers/stream-assemblers.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { z } from "zod"; - -import { anthropicStopReason } from "./anthropic-stop.ts"; -import { rejectStoppedResponse } from "./stops.ts"; -import type { StreamAssembler, StreamDelta, StreamEvent } from "./types.ts"; - -const object = z.record(z.string(), z.json()); -const indexSchema = z.number().int().nonnegative(); -const usageDelta = (value: unknown): StreamDelta[] => - value == null ? [] : [{ usage: object.parse(value) }]; -function streamFailure(message: string, details: Record): Error { - return Object.assign(new Error(`${message}: ${JSON.stringify(details)}`), { - providerError: details, - }); -} -function body(event: StreamEvent) { - const value = object.parse(JSON.parse(event.data)); - rejectStoppedResponse(value); - if (value.error || event.event === "error" || value.type === "error") - throw streamFailure("Provider stream error", value); - if (event.event && value.type && event.event !== value.type) - throw new Error("Mismatched SSE event type"); - return value; -} -function incomplete(): never { - throw new Error("Incomplete completion stream"); -} - -export function chatAssembler(): StreamAssembler { - let done = false; - let finish: string | undefined; - let text = ""; - let usage: Record | undefined; - const calls = new Map< - number, - { id: string; type: "function"; function: { name: string; arguments: string } } - >(); - return { - push(event) { - if (done) throw new Error("Data after completion terminator"); - if (event.data === "[DONE]") { - if (!finish) incomplete(); - done = true; - return []; - } - const chunk = body(event); - const choices = z - .array( - z.object({ - index: z.literal(0), - delta: z.strictObject({ - role: z.literal("assistant").optional(), - content: z.string().nullish(), - reasoning_content: z.string().nullish(), - refusal: z.string().nullish(), - tool_calls: z - .array( - z.object({ - index: indexSchema, - id: z.string().optional(), - type: z.literal("function").optional(), - function: z - .object({ name: z.string().optional(), arguments: z.string().optional() }) - .optional(), - }), - ) - .optional(), - }), - finish_reason: z.enum(["stop", "tool_calls"]).nullish(), - }), - ) - .max(1) - .parse(chunk.choices); - if (chunk.usage != null) usage = object.parse(chunk.usage); - const deltas = usageDelta(chunk.usage); - for (const choice of choices) { - if (finish) throw new Error("Choice after finish reason"); - if (choice.delta.refusal) throw new Error("Provider refused completion"); - if (choice.delta.reasoning_content) - deltas.push({ thinking: choice.delta.reasoning_content }); - if (choice.delta.content) { - text += choice.delta.content; - deltas.push({ text: choice.delta.content }); - } - for (const part of choice.delta.tool_calls ?? []) { - let call = calls.get(part.index); - if (!call) { - if (part.index !== calls.size) throw new Error("Out-of-order tool call index"); - call = { id: "", type: "function", function: { name: "", arguments: "" } }; - calls.set(part.index, call); - } - if (part.id) { - if (call.id) throw new Error("Duplicate tool call id fragment"); - call.id = part.id; - } - call.function.name += part.function?.name ?? ""; - call.function.arguments += part.function?.arguments ?? ""; - } - if (choice.finish_reason) finish = choice.finish_reason; - } - return deltas; - }, - finish() { - if (!done || !finish) incomplete(); - return { - ...(usage ? { usage } : {}), - choices: [ - { - finish_reason: finish, - message: { - role: "assistant", - content: text, - ...(calls.size ? { tool_calls: [...calls.values()] } : {}), - }, - }, - ], - }; - }, - }; -} - -export function anthropicAssembler(): StreamAssembler { - let message: Record | undefined; - let stopped = false; - let reason: string | undefined; - const blocks: Record[] = []; - let active: { index: number; block: Record; json: string } | undefined; - return { - push(event) { - const item = body(event); - if (item.type === "ping") return []; - if (stopped) throw new Error("Data after message_stop"); - if (item.type === "message_start") { - if (message) throw new Error("Duplicate message_start"); - message = object.parse(item.message); - z.object({ role: z.literal("assistant"), content: z.array(z.unknown()).length(0) }).parse( - message, - ); - return usageDelta(message.usage); - } - if (!message) throw new Error("Missing message_start"); - switch (item.type) { - case "content_block_start": { - if (active || reason || indexSchema.parse(item.index) !== blocks.length) - throw new Error("Invalid content block start"); - const block = object.parse(item.content_block); - z.enum(["text", "thinking", "redacted_thinking", "tool_use"]).parse(block.type); - active = { index: blocks.length, block, json: "" }; - return block.type === "text" && typeof block.text === "string" - ? [{ text: block.text }] - : block.type === "thinking" && typeof block.thinking === "string" - ? [{ thinking: block.thinking }] - : []; - } - case "content_block_delta": { - if (!active || item.index !== active.index) - throw new Error("Delta without active content block"); - const delta = object.parse(item.delta); - const field = - delta.type === "text_delta" - ? "text" - : delta.type === "thinking_delta" - ? "thinking" - : delta.type === "signature_delta" - ? "signature" - : undefined; - if (field) { - if (active.block.type !== (field === "text" ? "text" : "thinking")) - throw new Error("Wrong block delta type"); - const value = z.string().parse(delta[field]); - active.block[field] = z.string().parse(active.block[field] ?? "") + value; - return field === "signature" ? [] : [{ [field]: value }]; - } - if (delta.type !== "input_json_delta" || active.block.type !== "tool_use") - throw new Error("Unsupported block delta"); - active.json += z.string().parse(delta.partial_json); - return []; - } - case "content_block_stop": - if (!active || item.index !== active.index) - throw new Error("Stop without active content block"); - if (active.json) active.block.input = object.parse(JSON.parse(active.json)); - blocks.push(active.block); - active = undefined; - return []; - case "message_delta": { - if (active || reason) throw new Error("Invalid message_delta"); - const delta = object.parse(item.delta); - if (item.usage != null) - message.usage = { ...object.parse(message.usage ?? {}), ...object.parse(item.usage) }; - reason = anthropicStopReason(delta.stop_reason, item.usage); - return usageDelta(item.usage); - } - case "message_stop": - if (active || !reason) incomplete(); - stopped = true; - return []; - default: - throw new Error("Unsupported Anthropic stream event"); - } - }, - finish() { - if (!stopped || !message || !reason) incomplete(); - return { ...message, content: blocks, stop_reason: reason }; - }, - }; -} - -export function responsesAssembler(): StreamAssembler { - let started = false; - let completed: Record | undefined; - let responseId: string | undefined; - let sequence = -1; - return { - push(event) { - const item = body(event); - if (completed) throw new Error("Data after response.completed"); - if (item.sequence_number !== undefined) { - const next = indexSchema.parse(item.sequence_number); - if (next <= sequence) throw new Error("Out-of-order response event"); - sequence = next; - } - if (item.type === "response.created") { - if (started) throw new Error("Duplicate response.created"); - const response = object.parse(item.response); - responseId = z.string().parse(response.id); - started = true; - return []; - } - if (!started) throw new Error("Missing response.created"); - switch (item.type) { - case "response.failed": - case "response.incomplete": { - const response = object.parse(item.response); - rejectStoppedResponse(response); - throw streamFailure(`Provider stream ${item.type}`, { - type: item.type, - responseId: response.id ?? null, - status: response.status ?? null, - error: response.error ?? null, - incomplete_details: response.incomplete_details ?? null, - usage: response.usage ?? null, - }); - } - case "response.completed": { - const response = object.parse(item.response); - rejectStoppedResponse(response); - if (response.id !== responseId || response.status !== "completed") - throw new Error("Invalid completed response"); - completed = response; - return usageDelta(response.usage); - } - case "response.output_text.delta": - return [{ text: z.string().parse(item.delta) }]; - case "response.reasoning_summary_text.delta": - case "response.reasoning_text.delta": - return [{ thinking: z.string().parse(item.delta) }]; - case "response.in_progress": - case "response.output_item.added": - case "response.output_item.done": - case "response.content_part.added": - case "response.content_part.done": - case "response.output_text.done": - case "response.function_call_arguments.delta": - case "response.function_call_arguments.done": - case "response.reasoning_summary_part.added": - case "response.reasoning_summary_part.done": - case "response.reasoning_summary_text.done": - case "response.reasoning_text.done": - return []; - default: - throw streamFailure("Unsupported Responses stream event", { type: item.type ?? null }); - } - }, - finish() { - return completed ?? incomplete(); - }, - }; -} - -export function googleAssembler(): StreamAssembler { - let finished = false; - let usageMetadata: Record | undefined; - const parts: Record[] = []; - return { - push(event) { - const chunk = body(event); - if (chunk.usageMetadata != null) usageMetadata = object.parse(chunk.usageMetadata); - const deltas = usageDelta(chunk.usageMetadata); - if (chunk.promptFeedback) { - const feedback = object.parse(chunk.promptFeedback); - if (feedback.blockReason && feedback.blockReason !== "BLOCK_REASON_UNSPECIFIED") - throw new Error("Blocked Google stream"); - } - const candidates = z - .array( - z.object({ - index: z.literal(0).optional(), - finishReason: z.literal("STOP").optional(), - content: z - .object({ role: z.literal("model").optional(), parts: z.array(object) }) - .optional(), - }), - ) - .max(1) - .parse(chunk.candidates ?? []); - for (const candidate of candidates) { - if (finished) throw new Error("Candidate after finish reason"); - for (const part of candidate.content?.parts ?? []) { - // A trailing signature-only fragment belongs to the preceding streamed part. - if (Object.keys(part).length === 1 && typeof part.thoughtSignature === "string") { - const previous = parts.at(-1); - if (!previous || previous.thoughtSignature) throw new Error("Orphan thought signature"); - previous.thoughtSignature = part.thoughtSignature; - } else parts.push(part); - if (typeof part.text === "string") - deltas.push(part.thought === true ? { thinking: part.text } : { text: part.text }); - } - if (candidate.finishReason) finished = true; - } - return deltas; - }, - finish() { - if (!finished) incomplete(); - return { - ...(usageMetadata ? { usageMetadata } : {}), - candidates: [{ finishReason: "STOP", content: { role: "model", parts } }], - }; - }, - }; -} diff --git a/packages/core-agent/providers/stream.ts b/packages/core-agent/providers/stream.ts deleted file mode 100644 index 60e553f1..00000000 --- a/packages/core-agent/providers/stream.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { noopEffects, type EffectEmitter, type ProviderStreamContext } from "../effects/index.ts"; -import { notify } from "../host/notifications.ts"; -import { diagnosticError } from "../logging/index.ts"; -import type { StreamAssembler, StreamDeltaSink, StreamEvent } from "./types.ts"; - -/** SSE framing is transport-owned; dialect assembly is an operation-local profile resource. */ -export async function assembleStream( - response: Response, - assembler: StreamAssembler, - signal: AbortSignal, - sink?: StreamDeltaSink, - context: ProviderStreamContext = {}, - secrets: readonly string[] = [], - effects: EffectEmitter = noopEffects, - captureChunk?: (text: string) => void | Promise, -): Promise { - if ( - !response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream") || - !response.body - ) { - void response.body?.cancel().catch(() => {}); - throw new Error("Expected an SSE completion body"); - } - const started = performance.now(); - let bytes = 0; - let frames = 0; - let deltas = 0; - const usage: Record = {}; - let lastEvent: string | undefined; - const terminalEvidence: Record = {}; - effects({ type: "provider.stream.started", context }); - const reader = response.body.getReader(); - const cancel = () => { - void reader.cancel().catch(() => {}); - }; - signal.addEventListener("abort", cancel, { once: true }); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let buffer = ""; - let data: string[] = []; - let event: string | undefined; - const line = (value: string) => { - if (!value) { - if (data.length) { - const frame: StreamEvent = { ...(event ? { event } : {}), data: data.join("\n") }; - if (frame.data.length > 16 * 1024 * 1024) - throw new Error("Completion SSE frame exceeds 16 MiB"); - frames++; - lastEvent = event; - // Read only terminal metadata, including frames rejected by the dialect parser. - try { - const value = JSON.parse(frame.data); - if (value && typeof value === "object") { - const snapshot = - value.usage ?? value.usageMetadata ?? value.message?.usage ?? value.response?.usage; - if (snapshot && typeof snapshot === "object") - for (const [key, count] of Object.entries(snapshot)) - if (typeof count === "number") usage[key] = count; - const evidence = { - eventType: value.type, - stopReason: value.stop_reason ?? value.delta?.stop_reason, - finishReasons: Array.isArray(value.choices) - ? value.choices.map((choice: { finish_reason?: unknown }) => choice?.finish_reason) - : Array.isArray(value.candidates) - ? value.candidates.map( - (candidate: { finishReason?: unknown }) => candidate?.finishReason, - ) - : undefined, - }; - for (const [key, value] of Object.entries(evidence)) { - if (value == null) continue; - if (Array.isArray(value)) { - const reasons = value.filter((reason) => reason != null); - if (reasons.length) terminalEvidence[key] = reasons; - } else terminalEvidence[key] = value; - } - } - } catch { - /* The assembler reports malformed JSON with its original cause. */ - } - for (const delta of assembler.push(frame)) { - deltas++; - if (delta.usage) - for (const [key, value] of Object.entries(delta.usage)) - if (typeof value === "number") usage[key] = value; - signal.throwIfAborted(); - notify(sink, delta); - } - } - data = []; - event = undefined; - return; - } - if (value.startsWith(":")) return; - const colon = value.indexOf(":"); - const field = colon < 0 ? value : value.slice(0, colon); - const raw = colon < 0 ? "" : value.slice(colon + 1); - const content = raw.startsWith(" ") ? raw.slice(1) : raw; - if (field === "data") data.push(content); - if (field === "event") event = content; - }; - try { - for (;;) { - signal.throwIfAborted(); - const chunk = await reader.read(); - signal.throwIfAborted(); - if (chunk.value) bytes += chunk.value.byteLength; - const text = chunk.done ? decoder.decode() : decoder.decode(chunk.value, { stream: true }); - await captureChunk?.(text); - buffer += text; - for (;;) { - const index = buffer.search(/[\r\n]/); - if (index < 0 || (!chunk.done && buffer[index] === "\r" && index === buffer.length - 1)) - break; - const width = buffer[index] === "\r" && buffer[index + 1] === "\n" ? 2 : 1; - line(buffer.slice(0, index)); - buffer = buffer.slice(index + width); - } - if (buffer.length + data.reduce((sum, item) => sum + item.length, 0) > 16 * 1024 * 1024) - throw new Error("Completion SSE frame exceeds 16 MiB"); - if (chunk.done) break; - } - if (buffer || data.length) throw new Error("Incomplete completion SSE frame"); - signal.throwIfAborted(); - const body = assembler.finish(); - effects({ - type: "provider.stream.completed", - context, - bytes, - frames, - deltas, - lastEvent, - usage, - terminalEvidence, - durationMs: Math.round(performance.now() - started), - }); - return body; - } catch (error) { - effects( - signal.aborted - ? { - type: "provider.stream.cancelled", - context, - bytes, - frames, - deltas, - lastEvent, - usage, - terminalEvidence, - bufferedCharacters: buffer.length, - durationMs: Math.round(performance.now() - started), - error: diagnosticError(error, secrets), - } - : { - type: "provider.stream.failed", - context, - bytes, - frames, - deltas, - lastEvent, - usage, - terminalEvidence, - bufferedCharacters: buffer.length, - durationMs: Math.round(performance.now() - started), - error: diagnosticError(error, secrets), - }, - ); - throw error; - } finally { - signal.removeEventListener("abort", cancel); - void reader.cancel().catch(() => {}); - reader.releaseLock(); - } -} diff --git a/packages/core-agent/providers/streaming-profiles.ts b/packages/core-agent/providers/streaming-profiles.ts deleted file mode 100644 index f1003de1..00000000 --- a/packages/core-agent/providers/streaming-profiles.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { z } from "zod"; - -import { anthropicMessagesProfile, anthropicMessagesV2 } from "./anthropic-messages-v2.ts"; -import { googleGenerateV2 } from "./google-generate-v2.ts"; -import { openaiChat } from "./openai-chat.ts"; -import { openaiResponsesV2 } from "./openai-responses-v2.ts"; -import { - anthropicAssembler, - chatAssembler, - googleAssembler, - responsesAssembler, -} from "./stream-assemblers.ts"; -import { parseRequest, type CompletionProfile } from "./types.ts"; - -/** New IDs preserve the base dialect's payload codec; owner/provider joins use the new ID. */ -function streamingProfile( - base: CompletionProfile, - id: string, - stream: NonNullable, - dialect: "chat" | "google" | "messages" | "responses", -): CompletionProfile { - return { - ...base, - id, - capabilities: { ...base.capabilities, stream: true }, - stream, - encode(raw, blobs) { - const request = parseRequest(raw, id, true); - const encoded = base.encode( - { - ...request, - stream: false, - ...(request.provider ? { provider: base.id } : {}), - ...(request.continuations - ? { - continuations: request.continuations.map((entry) => ({ - ...entry, - provider: base.id, - })), - } - : {}), - }, - blobs, - ); - if (!request.stream) return encoded; - const body = z.record(z.string(), z.unknown()).parse(encoded.body); - if (dialect === "google") - return { - ...encoded, - path: encoded.path.replace(/:generateContent$/, ":streamGenerateContent"), - query: { alt: "sse" }, - }; - return { - ...encoded, - body: { - ...body, - stream: true, - ...(dialect === "chat" ? { stream_options: { include_usage: true } } : {}), - }, - }; - }, - }; -} -export const openaiChatV2 = streamingProfile(openaiChat, "openai-chat@2", chatAssembler, "chat"); -export const anthropicMessagesV3 = streamingProfile( - anthropicMessagesV2, - "anthropic-messages@3", - anthropicAssembler, - "messages", -); -export const anthropicMessagesV4 = streamingProfile( - anthropicMessagesProfile("anthropic-messages@4", true), - "anthropic-messages@4", - anthropicAssembler, - "messages", -); -export const googleGenerateV3 = streamingProfile( - googleGenerateV2, - "google-generate@3", - googleAssembler, - "google", -); -export const openaiResponsesV3 = streamingProfile( - openaiResponsesV2, - "openai-responses@3", - responsesAssembler, - "responses", -); diff --git a/packages/core-agent/providers/streaming.test.ts b/packages/core-agent/providers/streaming.test.ts deleted file mode 100644 index 87c463e7..00000000 --- a/packages/core-agent/providers/streaming.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { - anthropicMessagesV2, - bindProviders, - CompletionRequestSchema, - googleGenerateV2, - openaiChat, - openaiResponsesV2, - type StreamDelta, -} from "./index.ts"; -import { assembleStream } from "./stream.ts"; -import { - call, - sse, - streamingProfiles, - streamResponse, - streamVector, - text, -} from "./testing/stream-vectors.ts"; - -const signal = () => new AbortController().signal; -for (const profile of streamingProfiles) { - test(`${profile.id} streams text/thinking/usage, assembles tool arguments and decodes exactly once`, async () => { - for (const tools of [false, true]) { - let decodes = 0; - const deltas: StreamDelta[] = []; - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile: { - ...profile, - decode(...args) { - decodes++; - return profile.decode(...args); - }, - }, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - streamResponse(streamVector(profile, tools), true)) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - const result = await port.complete( - PreparedModelSchema.parse({ - thinkingBudgetTokens: profile.capabilities.thinking.mode === "budget" ? 1024 : null, - provider: profile.id, - model: "m", - stream: true, - thinking: - profile.id.startsWith("anthropic") || profile.id.startsWith("google") - ? "budget" - : "high", - maxOutputTokens: 2048, - messages: [], - }), - signal(), - undefined, - (delta) => { - deltas.push(delta); - expect(Object.isFrozen(delta)).toBe(true); - }, - ); - expect(result.completion).toEqual( - tools ? { kind: "tools", text, calls: [call] } : { kind: "answer", text }, - ); - expect(decodes).toBe(1); - expect(deltas.map((delta) => delta.text ?? "").join("")).toBe(text); - expect(deltas.some((delta) => delta.usage)).toBe(true); - expect(deltas.map((delta) => delta.thinking ?? "").join("")).toBe("Considering"); - if (profile.id !== "openai-chat@2") { - expect(JSON.stringify(result.continuationPayload)).toContain("signature"); - } - } - }); - test(`${profile.id} rejects EOF before every required terminal marker without decoding`, async () => { - const events = streamVector(profile, true); - for (const end of [0, 1, events.length - 1]) { - let decoded = false; - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile: { - ...profile, - decode(...args) { - decoded = true; - return profile.decode(...args); - }, - }, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - streamResponse(events.slice(0, end))) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - PreparedModelSchema.parse({ - maxOutputTokens: 16384, - provider: profile.id, - model: "m", - stream: true, - messages: [], - }), - signal(), - ), - ).rejects.toThrow(); - expect(decoded).toBe(false); - } - }); - test(`${profile.id} preserves nonstream wire behavior and selects its stream endpoint`, () => { - const base = [openaiChat, anthropicMessagesV2, googleGenerateV2, openaiResponsesV2][ - streamingProfiles.indexOf(profile) - ]!; - const request = CompletionRequestSchema.parse({ - maxOutputTokens: 16384, - model: "m", - messages: [], - tools: [], - successors: [], - }); - expect(profile.encode(request)).toEqual(base.encode(request)); - const stream = profile.encode({ ...request, stream: true }); - if (profile.id.startsWith("google")) { - expect(stream.path).toEndWith(":streamGenerateContent"); - expect(stream.query).toEqual({ alt: "sse" }); - } else expect(stream.body).toMatchObject({ stream: true }); - expect(() => base.encode({ ...request, stream: true })).toThrow("Unsupported streaming"); - }); -} - -test("SSE handles CRLF, multiline data, comments and split UTF-8; malformed frames fail closed", async () => { - const profile = streamingProfiles[0]!; - const events = streamVector(profile); - const source = - ": keepalive\r\n\r\n" + - sse(events) - .replaceAll('data: {"choices":', 'data: {\ndata: "choices":') - .replaceAll("\n", "\r\n"); - const response = new Response(source, { headers: { "content-type": "text/event-stream" } }); - expect(await assembleStream(response, profile.stream!(), signal())).toMatchObject({ - choices: [{ message: { content: text } }], - }); - for (const body of [ - sse(events).slice(0, -2), - "data: invalid\n\n", - 'data: {"error":"provider broke"}\n\n', - ]) - await expect( - assembleStream( - new Response(body, { headers: { "content-type": "text/event-stream" } }), - profile.stream!(), - signal(), - ), - ).rejects.toThrow(); -}); - -test("abort cancels a blocked reader and never decodes a partial response", async () => { - let cancelled = false; - let entered = false; - const controller = new AbortController(); - const response = new Response( - new ReadableStream({ - start(stream) { - stream.enqueue( - new TextEncoder().encode( - 'data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}\n\n', - ), - ); - }, - cancel() { - cancelled = true; - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ); - const pending = assembleStream( - response, - streamingProfiles[0]!.stream!(), - controller.signal, - () => { - entered = true; - controller.abort(); - }, - ); - await expect(pending).rejects.toThrow(); - expect(entered).toBe(true); - expect(cancelled).toBe(true); -}); - -test("stream observers cannot change assembled results or fail the transport", async () => { - for (const sink of [ - () => { - throw new Error("display failure"); - }, - () => Promise.reject(new Error("async display failure")), - () => new Promise(() => {}), - ]) { - const profile = streamingProfiles[0]!; - const result = await assembleStream( - streamResponse(streamVector(profile)), - profile.stream!(), - signal(), - sink, - ); - expect(result).toMatchObject({ choices: [{ message: { content: text } }] }); - } -}); - -test("malformed terminal data, refusals and provider errors fail each dialect", async () => { - for (const profile of streamingProfiles) { - const vector = streamVector(profile, true); - const error = { - event: "error", - data: JSON.stringify({ type: "error", error: { message: "broken" } }), - }; - await expect( - assembleStream(streamResponse([...vector.slice(0, 1), error]), profile.stream!(), signal()), - ).rejects.toThrow(); - await expect( - assembleStream(streamResponse([...vector, vector[0]!]), profile.stream!(), signal()), - ).rejects.toThrow(); - } - const profile = streamingProfiles[0]!; - const refusal = { - data: JSON.stringify({ - choices: [{ index: 0, delta: { refusal: "No" }, finish_reason: "stop" }], - }), - }; - await expect( - assembleStream(streamResponse([refusal, { data: "[DONE]" }]), profile.stream!(), signal()), - ).rejects.toThrow("refused"); - const badUtf8 = new Response(new Uint8Array([255]), { - headers: { "content-type": "text/event-stream" }, - }); - await expect(assembleStream(badUtf8, profile.stream!(), signal())).rejects.toThrow(); - await expect(assembleStream(Response.json({}), profile.stream!(), signal())).rejects.toThrow( - "SSE", - ); -}); - -test("transport rejects unsupported streaming before fetch", async () => { - let fetches = 0; - const port = bindProviders( - new Map([ - [ - openaiChat.id, - { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => { - fetches++; - return Response.json({}); - }) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - PreparedModelSchema.parse({ - maxOutputTokens: 16384, - provider: openaiChat.id, - model: "m", - stream: true, - messages: [], - }), - signal(), - ), - ).rejects.toThrow("Unsupported streaming"); - expect(fetches).toBe(0); -}); - -for (const profile of streamingProfiles) - test(`${profile.id} assembles parallel tools without executing or losing argument fragments`, async () => { - const frames = streamVector(profile, true).map((frame) => ({ ...frame })); - const second = { id: "c2", name: "echo", args: { text: "second" } }; - const event = (data: { type?: string; [key: string]: unknown }) => ({ - ...(data.type ? { event: data.type } : {}), - data: JSON.stringify(data), - }); - if (profile.id === "openai-chat@2") { - frames.splice( - 4, - 0, - event({ - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 1, - id: "c2", - type: "function", - function: { name: "echo", arguments: '{"text":' }, - }, - ], - }, - }, - ], - }), - ); - frames.splice( - 6, - 0, - event({ - choices: [ - { - index: 0, - delta: { tool_calls: [{ index: 1, function: { arguments: '"second"}' } }] }, - }, - ], - }), - ); - } else if (profile.id.startsWith("anthropic")) { - frames.splice( - -2, - 0, - event({ - type: "content_block_start", - index: 3, - content_block: { type: "tool_use", id: "c2", name: "echo", input: {} }, - }), - event({ - type: "content_block_delta", - index: 3, - delta: { type: "input_json_delta", partial_json: JSON.stringify(second.args) }, - }), - event({ type: "content_block_stop", index: 3 }), - ); - } else if (profile.id.startsWith("google")) { - frames.splice( - -1, - 0, - event({ - candidates: [ - { - content: { - role: "model", - parts: [ - { - functionCall: { id: "c2", name: "echo", args: second.args }, - thoughtSignature: "second-signature", - }, - ], - }, - }, - ], - }), - ); - } else { - const terminal = JSON.parse(frames.at(-1)!.data); - terminal.response.output.push({ - type: "function_call", - call_id: "c2", - name: "echo", - arguments: JSON.stringify(second.args), - }); - frames[frames.length - 1] = event(terminal); - } - const body = await assembleStream(streamResponse(frames), profile.stream!(), signal()); - expect(profile.decode({ status: 200, headers: new Headers(), body }).completion).toEqual({ - kind: "tools", - text, - calls: [call, second], - }); - }); diff --git a/packages/core-agent/providers/testing/profile-contract.ts b/packages/core-agent/providers/testing/profile-contract.ts deleted file mode 100644 index 5e5575ec..00000000 --- a/packages/core-agent/providers/testing/profile-contract.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "@logtape/testing-bun/autoload"; - -import { CompletionSchema } from "../../agent/types.ts"; -import { CompletionRequestSchema, type CompletionProfile } from "../types.ts"; - -export type ProfileVector = Readonly<{ - answer: unknown; - calls: unknown; - handoff: unknown; - invalid: readonly unknown[]; - expectedBody: unknown; -}>; -export const request = CompletionRequestSchema.parse({ - model: "test-model", - messages: [ - { role: "system", text: "Be concise" }, - { role: "user", text: "Find it" }, - { role: "assistant", text: "", calls: [{ id: "c1", name: "lookup", args: { query: "a" } }] }, - { role: "tool", callId: "c1", text: "found" }, - ], - tools: [ - { - name: "lookup", - parameters: { - type: "object", - properties: { query: { type: "string" } }, - required: ["query"], - }, - }, - ], - successors: [], -}); -export function profileContract(profile: CompletionProfile, vector: ProfileVector) { - const response = (body: unknown) => ({ status: 200, headers: new Headers(), body }); - describe(profile.id, () => { - test("encodes the full canonical transcript against the approved wire vector", () => { - const before = JSON.stringify(request); - expect( - profile.encode({ - ...request, - ...(profile.capabilities.outputTokens?.required ? { maxOutputTokens: 1024 } : {}), - }).body, - ).toEqual(vector.expectedBody); - expect(JSON.stringify(request)).toBe(before); - }); - test("decodes answer, ordered parallel calls, and reserved handoff", () => { - expect(CompletionSchema.parse(profile.decode(response(vector.answer)).completion)).toEqual({ - kind: "answer", - text: "done", - }); - expect( - CompletionSchema.parse(profile.decode(response(vector.calls)).completion), - ).toMatchObject({ - kind: "tools", - calls: [ - { id: "c1", name: "lookup", args: { query: "a" } }, - { id: "c2", name: "lookup", args: { query: "b" } }, - ], - }); - expect(CompletionSchema.parse(profile.decode(response(vector.handoff)).completion)).toEqual( - CompletionSchema.parse({ kind: "handoff", text: "", agent: "b" }), - ); - }); - test("rejects malformed, incomplete, built-in-tool, and unsupported thinking responses", () => { - for (const body of vector.invalid) expect(() => profile.decode(response(body))).toThrow(); - expect(() => profile.decode({ ...response(vector.answer), status: 503 })).toThrow(); - }); - test("rejects unsupported settings before any I/O and reserves the handoff name", () => { - for (const patch of [ - { stream: true }, - { thinking: "adaptive" }, - { tools: [{ name: "handoff_to", parameters: {} }] }, - ]) - expect(() => profile.encode({ ...request, ...patch } as never)).toThrow(); - }); - test("rejects orphan and incomplete tool exchanges", () => { - expect(() => - profile.encode({ ...request, messages: request.messages.slice(0, -1) }), - ).toThrow(); - expect(() => profile.encode({ ...request, messages: [request.messages.at(-1)!] })).toThrow(); - }); - }); -} diff --git a/packages/core-agent/providers/testing/stream-vectors.ts b/packages/core-agent/providers/testing/stream-vectors.ts deleted file mode 100644 index 87c8e037..00000000 --- a/packages/core-agent/providers/testing/stream-vectors.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - anthropicMessagesV3, - googleGenerateV3, - openaiChatV2, - openaiResponsesV3, - type CompletionProfile, - type StreamEvent, -} from "../index.ts"; - -export const text = "Hello 🌍"; -export const call = { id: "c1", name: "echo", args: { text } }; -const frame = (data: unknown): StreamEvent => ({ data: JSON.stringify(data) }); -const named = (data: { type: string; [key: string]: unknown }): StreamEvent => ({ - event: data.type, - data: JSON.stringify(data), -}); -export function streamVector(profile: CompletionProfile, tools = false, signature = "signature") { - if (profile.id === openaiChatV2.id) { - const chunk = (delta: unknown, finish_reason: string | null = null) => - frame({ choices: [{ index: 0, delta, finish_reason }] }); - return [ - chunk({ reasoning_content: "Considering" }), - chunk({ role: "assistant", content: "Hello " }), - chunk({ content: "🌍" }), - ...(tools - ? [ - chunk({ - tool_calls: [ - { - index: 0, - id: "c1", - type: "function", - function: { name: "echo", arguments: '{"text":' }, - }, - ], - }), - chunk({ tool_calls: [{ index: 0, function: { arguments: '"Hello 🌍"}' } }] }), - ] - : []), - chunk({}, tools ? "tool_calls" : "stop"), - frame({ choices: [], usage: { completion_tokens: 4 } }), - { data: "[DONE]" }, - ]; - } - if (profile.id === anthropicMessagesV3.id) - return [ - named({ - type: "message_start", - message: { - id: "msg", - role: "assistant", - content: [], - stop_reason: null, - usage: { input_tokens: 1 }, - }, - }), - named({ - type: "content_block_start", - index: 0, - content_block: { type: "thinking", thinking: "", signature: "" }, - }), - named({ - type: "content_block_delta", - index: 0, - delta: { type: "thinking_delta", thinking: "Considering" }, - }), - named({ - type: "content_block_delta", - index: 0, - delta: { type: "signature_delta", signature }, - }), - named({ type: "content_block_stop", index: 0 }), - named({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }), - named({ - type: "content_block_delta", - index: 1, - delta: { type: "text_delta", text: "Hello " }, - }), - named({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "🌍" } }), - named({ type: "content_block_stop", index: 1 }), - ...(tools - ? [ - named({ - type: "content_block_start", - index: 2, - content_block: { type: "tool_use", id: "c1", name: "echo", input: {} }, - }), - named({ - type: "content_block_delta", - index: 2, - delta: { type: "input_json_delta", partial_json: '{"text":' }, - }), - named({ - type: "content_block_delta", - index: 2, - delta: { type: "input_json_delta", partial_json: '"Hello 🌍"}' }, - }), - named({ type: "content_block_stop", index: 2 }), - ] - : []), - named({ - type: "message_delta", - delta: { stop_reason: tools ? "tool_use" : "end_turn" }, - usage: { output_tokens: 4 }, - }), - named({ type: "message_stop" }), - ]; - if (profile.id === openaiResponsesV3.id) - return [ - named({ - type: "response.created", - response: { id: "resp", status: "in_progress", output: [] }, - }), - named({ type: "response.reasoning_summary_text.delta", delta: "Considering" }), - named({ type: "response.output_text.delta", delta: "Hello " }), - named({ type: "response.output_text.delta", delta: "🌍" }), - ...(tools - ? [ - named({ type: "response.function_call_arguments.delta", delta: '{"text":' }), - named({ - type: "response.function_call_arguments.done", - arguments: JSON.stringify(call.args), - }), - ] - : []), - named({ - type: "response.completed", - response: { - id: "resp", - status: "completed", - usage: { output_tokens: 4 }, - output: [ - { type: "reasoning", id: "rs_1", summary: [], encrypted_content: signature }, - { type: "message", role: "assistant", content: [{ type: "output_text", text }] }, - ...(tools - ? [ - { - type: "function_call", - call_id: "c1", - name: "echo", - arguments: JSON.stringify(call.args), - }, - ] - : []), - ], - }, - }), - ]; - if (profile.id === googleGenerateV3.id) { - const chunk = (parts: unknown[], finishReason?: string) => - frame({ - candidates: [ - { - index: 0, - content: { role: "model", parts }, - ...(finishReason ? { finishReason } : {}), - }, - ], - usageMetadata: { candidatesTokenCount: 4 }, - }); - return [ - chunk([{ text: "Considering", thought: true }]), - chunk([{ text: "Hello " }]), - chunk([{ text: "🌍", ...(tools ? {} : { thoughtSignature: signature }) }]), - ...(tools - ? [ - chunk([{ functionCall: { id: "c1", name: "echo", args: call.args } }]), - chunk([{ thoughtSignature: signature }]), - ] - : []), - chunk([], "STOP"), - ]; - } - throw new Error("Unknown stream fixture profile"); -} -export const streamingProfiles = [ - openaiChatV2, - anthropicMessagesV3, - googleGenerateV3, - openaiResponsesV3, -]; -export const sse = (events: readonly StreamEvent[]) => - events - .map((event) => `${event.event ? `event: ${event.event}\n` : ""}data: ${event.data}\n\n`) - .join(""); -export function streamResponse(events: readonly StreamEvent[], byteChunks = false) { - const bytes = new TextEncoder().encode(sse(events)); - return new Response( - new ReadableStream({ - start(controller) { - if (byteChunks) for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); - else controller.enqueue(bytes); - controller.close(); - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ); -} diff --git a/packages/core-agent/providers/thinking.test.ts b/packages/core-agent/providers/thinking.test.ts deleted file mode 100644 index 91af7ab2..00000000 --- a/packages/core-agent/providers/thinking.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { - anthropicMessages, - anthropicMessagesV2, - bindProviders, - CompletionRequestSchema, - ContinuationSchema, - googleGenerate, - openaiChat, - openaiResponses, - parseRequest, -} from "./index.ts"; - -const response = (content: unknown[]) => ({ - status: 200, - headers: new Headers(), - body: { role: "assistant", stop_reason: "tool_use", content }, -}); - -const thinking = (signature: string) => ({ type: "thinking", thinking: "private", signature }); - -const request = CompletionRequestSchema.parse({ - model: "claude-sonnet-4-5", - messages: [{ role: "user", text: "Go" }], - tools: [], - successors: [], - thinking: "budget", - thinkingBudgetTokens: 1024, - maxOutputTokens: 2048, -}); - -test("Anthropic manual thinking uses explicit budgets and output limits", () => { - expect(anthropicMessagesV2.encode(request).body).toEqual({ - model: request.model, - max_tokens: 2048, - system: [], - messages: [{ role: "user", content: [{ type: "text", text: "Go" }] }], - tools: [], - thinking: { type: "enabled", budget_tokens: 1024 }, - stream: false, - }); - expect( - anthropicMessagesV2.encode({ - ...request, - thinking: "off", - thinkingBudgetTokens: null, - maxOutputTokens: 8192, - }).body, - ).toMatchObject({ max_tokens: 8192, thinking: { type: "disabled" } }); - for (const maxOutputTokens of [undefined, 1024, 1]) - expect(() => anthropicMessagesV2.encode({ ...request, maxOutputTokens })).toThrow( - "maxOutputTokens", - ); -}); - -test("two owners replay their own blocks even when envelopes arrive reversed", () => { - const owners = [ - { turnId: "turn", generation: 2 }, - { turnId: "turn", generation: 5 }, - ]; - const continuations = owners.map((owner, i) => - ContinuationSchema.parse({ - provider: anthropicMessagesV2.id, - owner, - payload: { blocks: [thinking(String(i))] }, - }), - ); - const input = CompletionRequestSchema.parse({ - ...request, - messages: [ - request.messages[0], - ...owners.flatMap((owner, i) => [ - { - role: "assistant", - owner, - text: "work", - calls: [{ id: String(i), name: "echo", args: {} }], - }, - { role: "tool", callId: String(i), text: "ok" }, - ]), - ], - continuations: continuations.toReversed(), - }); - const body = anthropicMessagesV2.encode(input).body as { messages: { content: unknown[] }[] }; - for (const i of [0, 1]) - expect(body.messages[1 + i * 2]?.content).toEqual([ - thinking(String(i)), - { type: "text", text: "work" }, - { type: "tool_use", id: String(i), name: "echo", input: {} }, - ]); -}); - -test("decode preserves signed/redacted bytes and rejects unsigned payloads; large payloads reach session storage", () => { - const blocks = [thinking("sig"), { type: "redacted_thinking", data: "opaque" }]; - expect(anthropicMessagesV2.decode(response([...blocks, { type: "text", text: "done" }]))).toEqual( - { completion: { kind: "answer", text: "done" }, continuationPayload: { blocks } }, - ); - expect(() => - anthropicMessagesV2.decode(response([{ type: "thinking", thinking: "unsigned" }])), - ).toThrow(); - expect( - anthropicMessagesV2.decode(response([thinking("x".repeat(70000))])).continuationPayload, - ).toEqual({ blocks: [thinking("x".repeat(70000))] }); -}); - -test("capability intersection rejects before HTTP and chat maps effort", async () => { - for (const profile of [anthropicMessages, openaiChat, openaiResponses, googleGenerate]) { - let calls = 0; - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => { - calls++; - return Response.json({}); - }) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - PreparedModelSchema.parse({ - maxOutputTokens: 16384, - model: "test", - messages: [], - provider: profile.id, - thinking: "budget", - thinkingBudgetTokens: 1024, - }), - new AbortController().signal, - ), - ).rejects.toThrow("Unsupported thinking"); - expect(calls).toBe(0); - } - for (const thinking of ["low", "medium", "high"] as const) - expect( - openaiChat.encode({ - model: "openai-chat-test-model", - messages: [{ role: "user", text: "Go" }], - tools: [], - successors: [], - thinking, - }).body, - ).toMatchObject({ - reasoning_effort: thinking, - }); -}); - -test("parseRequest rejects orphan and foreign-provider continuations", () => { - const continuation = ContinuationSchema.parse({ - provider: anthropicMessagesV2.id, - owner: { turnId: "turn", generation: 2 }, - payload: { blocks: [thinking("sig")] }, - }); - const input = CompletionRequestSchema.parse({ - ...request, - provider: anthropicMessagesV2.id, - messages: [{ role: "assistant", owner: continuation.owner, text: "answer" }], - continuations: [continuation], - }); - expect(parseRequest(input).continuations).toEqual([continuation]); - expect(() => parseRequest({ ...input, messages: request.messages })).toThrow("no assistant"); - expect(() => - parseRequest({ - ...input, - continuations: [{ ...continuation, owner: { ...continuation.owner, generation: 3 } }], - }), - ).toThrow("no assistant"); - expect(() => parseRequest({ ...input, provider: googleGenerate.id })).toThrow("provider"); - expect(() => parseRequest({ ...input, provider: undefined })).toThrow("provider"); - expect(() => anthropicMessagesV2.encode({ ...input, provider: googleGenerate.id })).toThrow( - "provider", - ); - expect(() => - googleGenerate.encode({ - thinkingBudgetTokens: null, - ...input, - provider: undefined, - thinking: "off", - }), - ).toThrow("provider"); -}); - -test("Anthropic refuses adjacent assistant merges involving thinking and preserves plain merges", () => { - const continuations = [2, 5].map((generation) => - ContinuationSchema.parse({ - provider: anthropicMessagesV2.id, - owner: { turnId: "turn", generation }, - payload: { blocks: [thinking(String(generation))] }, - }), - ); - const input = CompletionRequestSchema.parse({ - ...request, - messages: continuations.map((entry, i) => ({ - role: "assistant", - owner: entry.owner, - text: `answer ${i}`, - })), - continuations: continuations.toReversed(), - }); - for (const entries of [ - input.continuations, - continuations.slice(0, 1), - continuations.slice(1), - [ - { - ...continuations[0]!, - payload: { blocks: [{ type: "redacted_thinking", data: "opaque" }] }, - }, - ], - ]) - expect(() => anthropicMessagesV2.encode({ ...input, continuations: entries })).toThrow( - "Cannot merge adjacent assistant", - ); - expect(anthropicMessagesV2.encode({ ...input, continuations: [] }).body).toMatchObject({ - messages: [ - { - role: "assistant", - content: [ - { type: "text", text: "answer 0" }, - { type: "text", text: "answer 1" }, - ], - }, - ], - }); -}); - -test("orphan and foreign continuations fail before fetch at the transport boundary", async () => { - let calls = 0; - const port = bindProviders( - new Map([ - [ - anthropicMessagesV2.id, - { - profile: anthropicMessagesV2, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => { - calls++; - return Response.json({}); - }) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - const continuation = ContinuationSchema.parse({ - provider: anthropicMessagesV2.id, - owner: { turnId: "turn", generation: 2 }, - payload: { blocks: [thinking("sig")] }, - }); - for (const patch of [ - { messages: [{ role: "user", content: "Go" }], continuations: [continuation] }, - { - messages: [{ role: "assistant", owner: continuation.owner, content: "answer" }], - continuations: [{ ...continuation, provider: googleGenerate.id }], - }, - ]) - await expect( - port.complete( - PreparedModelSchema.parse({ - maxOutputTokens: 16384, - model: "test", - provider: anthropicMessagesV2.id, - ...patch, - }), - new AbortController().signal, - ), - ).rejects.toThrow("Continuation"); - expect(calls).toBe(0); -}); diff --git a/packages/core-agent/providers/token-settings.test.ts b/packages/core-agent/providers/token-settings.test.ts deleted file mode 100644 index 97d7658b..00000000 --- a/packages/core-agent/providers/token-settings.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { createProviderCapture } from "../environment/provider-capture.ts"; -import { withFixtureDiagnostics } from "../logging/fixture-capture.ts"; -import { createSession, restoreSession, type SessionOptions } from "../session/index.ts"; -import { deferred, until } from "../session/test-support.ts"; -import { createMemoryPersistence } from "../session/testing/memory-persistence.ts"; -import { - anthropicMessagesV2, - anthropicMessagesV3, - anthropicMessagesV4, - CompletionRequestSchema, - googleGenerateV2, - googleGenerateV3, -} from "./index.ts"; - -for (const profile of [ - anthropicMessagesV2, - anthropicMessagesV3, - googleGenerateV2, - googleGenerateV3, -]) { - test(`${profile.id} encodes the chosen thinking budget without a fixed 1024-token substitution`, () => { - for (const budget of [4096, 8192, 16384]) { - const request = CompletionRequestSchema.parse({ - model: "configured-model", - messages: [{ role: "user", text: "Review" }], - tools: [], - successors: [], - thinking: "budget", - thinkingBudgetTokens: budget, - maxOutputTokens: 32768, - }); - const body = profile.encode(request).body; - expect(body).toMatchObject( - profile.id.startsWith("anthropic") - ? { thinking: { type: "enabled", budget_tokens: budget }, max_tokens: 32768 } - : { - generationConfig: { - thinkingConfig: { thinkingBudget: budget }, - maxOutputTokens: 32768, - }, - }, - ); - for (const patch of [ - { thinkingBudgetTokens: undefined }, - { maxOutputTokens: budget }, - { maxOutputTokens: undefined }, - ]) - expect(() => profile.encode({ ...request, ...patch })).toThrow(); - } - }); -} - -test("adaptive thinking requires an explicit output limit and sends no manual budget", () => { - const request = CompletionRequestSchema.parse({ - model: "adaptive-model", - messages: [], - tools: [], - successors: [], - thinking: "adaptive", - }); - expect(() => anthropicMessagesV4.encode(request)).toThrow("maxOutputTokens"); - expect(anthropicMessagesV4.encode({ ...request, maxOutputTokens: 32768 }).body).toMatchObject({ - thinking: { type: "adaptive" }, - max_tokens: 32768, - }); - expect( - JSON.stringify(anthropicMessagesV4.encode({ ...request, maxOutputTokens: 32768 }).body), - ).not.toContain("budget_tokens"); - expect(() => - anthropicMessagesV4.encode({ ...request, maxOutputTokens: 32768, thinkingBudgetTokens: 4096 }), - ).toThrow("clear the budget"); -}); - -test("explicit token settings validate before admission, stay captured in flight, and restore without requests", async () => { - const capture = await createProviderCapture(".session-artifacts/token-settings"); - const release = deferred(); - const requests: any[] = []; - const profile = { - ...anthropicMessagesV2, - capabilities: { - ...anthropicMessagesV2.capabilities, - thinking: { mode: "budget" as const, minTokens: 1024, maxTokens: 16384 }, - outputTokens: { required: true, maxTokens: 32768 }, - }, - }; - const options: SessionOptions = { - persistence: createMemoryPersistence(), - configuration: { - agent: "reviewer", - agents: new Map([["reviewer", { model: "review" }]]), - steps: 2, - policy: { - provider: "anthropic", - model: "review", - thinking: "budget", - thinkingBudgetTokens: 8192, - maxOutputTokens: 32768, - }, - }, - bindings: { - providers: new Map([ - [ - "anthropic", - { - profile, - models: new Map([["review", { wireModel: "configured-model", profile }]]), - transport: { - baseUrl: "https://scripted.invalid/v1", - capture: capture.capture, - fetch: (async (_url, init) => { - requests.push(JSON.parse(String(init?.body))); - if (requests.length === 1) await release.promise; - return Response.json({ - role: "assistant", - stop_reason: "end_turn", - content: [{ type: "text", text: "Reviewed" }], - }); - }) as typeof fetch, - }, - }, - ], - ]), - }, - }; - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - for (const patch of [ - { thinkingBudgetTokens: null }, - { thinkingBudgetTokens: 65536 }, - { maxOutputTokens: 8192 }, - { maxOutputTokens: 65536 }, - ]) - await expect( - createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { ...options.configuration.policy, ...patch }, - }, - }), - ).rejects.toThrow(); - expect(requests).toHaveLength(0); - const session = await createSession(options); - try { - const first = session.input("First review"); - await until(() => requests.length === 1); - expect(await session.updatePolicy({ thinkingBudgetTokens: 4096 })).toMatchObject({ - kind: "selected", - policy: { thinkingBudgetTokens: 4096 }, - }); - expect(session.selectedPolicy).toMatchObject({ thinkingBudgetTokens: 4096 }); - expect(session.policy).toMatchObject({ thinkingBudgetTokens: 8192 }); - expect(requests[0]).toMatchObject({ thinking: { budget_tokens: 8192 }, max_tokens: 32768 }); - release.resolve(); - expect(await first.settled).toMatchObject({ record: { outcome: { kind: "completed" } } }); - expect(await session.updatePolicy({ maxOutputTokens: 16384 })).toMatchObject({ - kind: "accepted", - }); - await session.input("Second review").settled; - expect(requests[1]).toMatchObject({ thinking: { budget_tokens: 4096 }, max_tokens: 16384 }); - const restored = await restoreSession( - options, - session.snapshot.durable.conversation.sessionId, - ); - expect(requests).toHaveLength(2); - expect(restored.snapshot.durable.policy).toMatchObject({ - thinkingBudgetTokens: 4096, - maxOutputTokens: 16384, - }); - await restored.close(); - } finally { - release.resolve(); - await session.close(); - } - }); - const logs = (await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(logs.filter((log) => ["warning", "error"].includes(log.level))).toEqual([]); - expect( - logs.some( - (log) => log.event === "configuration.applied" && log.policy.thinkingBudgetTokens === 4096, - ), - ).toBe(true); - const manifest = await Bun.file(`${capture.directory}/manifest.json`).json(); - expect(manifest.calls).toHaveLength(2); - expect( - JSON.parse(await Bun.file(`${capture.directory}/${manifest.calls[1].requestFile}`).text()), - ).toMatchObject({ thinking: { budget_tokens: 4096 }, max_tokens: 16384 }); -}); diff --git a/packages/core-agent/providers/transport.test.ts b/packages/core-agent/providers/transport.test.ts deleted file mode 100644 index de833335..00000000 --- a/packages/core-agent/providers/transport.test.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { deferred } from "../agent/test-support.ts"; -import { openaiChat } from "./openai-chat.ts"; -import { bindProviders, httpTransport } from "./transport.ts"; - -const prepared = PreparedModelSchema.parse({ - provider: openaiChat.id, - model: "m", - maxOutputTokens: 16384, - messages: [{ role: "user", content: "hi" }], - successors: ["b"], -}); -test("bindings copy credentials and registries, issue one request, and keep secrets off domain values", async () => { - const requests: { url: string; init?: RequestInit }[] = []; - const headers = { Authorization: "Bearer SECRET" }; - const binding = { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid/v1", - headers, - fetch: (async (url, init) => { - requests.push({ url: String(url), init }); - return Response.json({ choices: [{ message: { content: "done" } }] }); - }) as typeof fetch, - }, - }; - const registry = new Map([[openaiChat.id, binding]]); - const port = bindProviders(registry); - registry.clear(); - headers.Authorization = "changed"; - expect(await port.complete(prepared, new AbortController().signal)).toEqual({ - completion: { kind: "answer", text: "done" }, - }); - expect(requests).toHaveLength(1); - expect(requests[0]!.url).toBe("https://example.invalid/v1/chat/completions"); - expect(requests[0]!.init?.headers).toMatchObject({ Authorization: "Bearer SECRET" }); - expect(JSON.stringify(prepared)).not.toContain("SECRET"); - expect(JSON.stringify(prepared)).not.toContain("baseUrl"); -}); -test("HTTP failure never retries and retains provider body; invalid JSON fails", async () => { - let calls = 0; - const http = httpTransport({ - baseUrl: "https://example.invalid/v1", - fetch: (async () => { - calls++; - return new Response("SECRET", { status: 503 }); - }) as unknown as typeof fetch, - }); - await expect( - http({ path: "/test", method: "POST", headers: {}, body: {} }, new AbortController().signal), - ).rejects.toThrow("Completion HTTP failure (503)"); - expect(calls).toBe(1); - const invalid = httpTransport({ - baseUrl: "https://example.invalid/v1", - fetch: (async () => new Response("{")) as unknown as typeof fetch, - }); - await expect( - invalid({ path: "/test", method: "POST", headers: {}, body: {} }, new AbortController().signal), - ).rejects.toThrow("not valid JSON"); -}); -test("cancellation reaches fetch and rejects a late response", async () => { - const pending = deferred(); - const dispatched = deferred(); - let seen: AbortSignal | null | undefined; - const http = httpTransport({ - baseUrl: "https://example.invalid", - fetch: (async (_url, init) => { - seen = init?.signal; - dispatched.resolve(); - return pending.promise; - }) as typeof fetch, - }); - const controller = new AbortController(); - const result = http({ path: "/test", method: "POST", headers: {}, body: {} }, controller.signal); - await dispatched.promise; - controller.abort(); - pending.resolve(Response.json({})); - await expect(result).rejects.toThrow(); - expect(seen).toBe(controller.signal); - expect(seen?.aborted).toBe(true); -}); -test("unadvertised handoffs are rejected at the binding boundary", async () => { - const port = bindProviders( - new Map([ - [ - openaiChat.id, - { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - Response.json({ - choices: [ - { - message: { - tool_calls: [ - { - id: "h", - type: "function", - function: { name: "handoff_to", arguments: '{"agent":"c"}' }, - }, - ], - }, - }, - ], - })) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect(port.complete(prepared, new AbortController().signal)).rejects.toThrow( - "Unpermitted handoff", - ); -}); - -test("prepared domain input rejects transport resources and credentials", () => { - for (const extra of [ - { baseUrl: "https://example.invalid" }, - { apiKey: "SECRET" }, - { signal: new AbortController().signal }, - ]) - expect(PreparedModelSchema.safeParse({ ...prepared, ...extra }).success).toBe(false); -}); - -test("provider diagnostics retain correlated HTTP causes while excluding credential echoes", async () => { - const { getLogger } = await import("@logtape/logtape"); - const { spyOn } = await import("bun:test"); - const logger = getLogger(["labkit", "provider"]); - const warning = spyOn(logger, "emit"); - const debug = warning; - try { - const port = bindProviders( - new Map([ - [ - openaiChat.id, - { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid/v1", - headers: { "x-api-key": "private-key-value" }, - fetch: (async () => - Response.json( - { - error: { - type: "invalid_request_error", - message: "budget invalid; key private-key-value", - }, - }, - { status: 400, headers: { "request-id": "provider-123" } }, - )) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete(prepared, new AbortController().signal, undefined, undefined, { - sessionId: "s", - turnId: "t", - childId: "c", - }), - ).rejects.toThrow("budget invalid; key [REDACTED]"); - const rejected = warning.mock.calls.find( - ([event]) => event.rawMessage === "provider.http.rejected", - ); - expect(rejected?.[0].properties).toMatchObject({ - sessionId: "s", - turnId: "t", - childId: "c", - provider: openaiChat.id, - model: "m", - httpStatus: 400, - providerRequestId: "provider-123", - }); - expect(JSON.stringify(rejected)).toContain("invalid_request_error"); - expect(JSON.stringify(warning.mock.calls)).not.toContain("private-key-value"); - expect(debug.mock.calls.some(([event]) => event.rawMessage === "provider.http.started")).toBe( - true, - ); - } finally { - warning.mockRestore(); - debug.mockRestore(); - } -}); - -test("provider success and decode failure logs preserve usage, limits and operation identities", async () => { - const { getLogger } = await import("@logtape/logtape"); - const { spyOn } = await import("bun:test"); - const { anthropicMessagesV4 } = await import("./streaming-profiles.ts"); - const logger = getLogger(["labkit", "provider"]); - const warning = spyOn(logger, "emit"); - const info = warning; - try { - const success = bindProviders( - new Map([ - [ - openaiChat.id, - { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - Response.json({ - choices: [{ finish_reason: "stop", message: { content: "done" } }], - usage: { completion_tokens: 7 }, - })) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await success.complete(prepared, new AbortController().signal, undefined, undefined, { - childId: "success", - }); - expect( - info.mock.calls.find(([event]) => event.rawMessage === "provider.completion.completed")?.[0] - .properties, - ).toMatchObject({ - childId: "success", - completionKind: "answer", - usage: { completion_tokens: 7 }, - }); - const profile = anthropicMessagesV4; - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - Response.json({ - content: [], - stop_reason: "max_tokens", - usage: { output_tokens: 4096 }, - })) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - { ...prepared, provider: profile.id, maxOutputTokens: 4096 }, - new AbortController().signal, - undefined, - undefined, - { childId: "failed" }, - ), - ).rejects.toThrow("max_tokens"); - expect( - warning.mock.calls.find(([event]) => event.rawMessage === "provider.completion.failed")?.[0] - .properties, - ).toMatchObject({ - childId: "failed", - phase: "decode", - maxOutputTokens: 4096, - stopReason: "max_tokens", - usage: { output_tokens: 4096 }, - }); - } finally { - warning.mockRestore(); - info.mockRestore(); - } -}); - -test("truncated streams and cancellation expose progress and never report completion", async () => { - const { getLogger } = await import("@logtape/logtape"); - const { spyOn } = await import("bun:test"); - const { openaiChatV2 } = await import("./streaming-profiles.ts"); - const emitted = spyOn(getLogger(["labkit", "provider"]), "emit"); - try { - const port = bindProviders( - new Map([ - [ - openaiChatV2.id, - { - profile: openaiChatV2, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - new Response('data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}\n\n', { - headers: { "content-type": "text/event-stream" }, - })) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - { ...prepared, provider: openaiChatV2.id, stream: true }, - new AbortController().signal, - undefined, - undefined, - { childId: "truncated" }, - ), - ).rejects.toThrow(); - const failure = emitted.mock.calls.find( - ([record]) => record.rawMessage === "provider.stream.failed", - )?.[0].properties; - expect(failure).toMatchObject({ childId: "truncated", frames: 1, deltas: 1 }); - expect(failure?.bytes).toBeGreaterThan(0); - expect( - emitted.mock.calls.some(([record]) => record.rawMessage === "provider.completion.completed"), - ).toBe(false); - const controller = new AbortController(); - controller.abort(new Error("user cancelled review")); - await expect( - port.complete( - { ...prepared, provider: openaiChatV2.id, stream: true }, - controller.signal, - undefined, - undefined, - { childId: "cancelled" }, - ), - ).rejects.toThrow("user cancelled review"); - expect( - emitted.mock.calls.find( - ([record]) => record.rawMessage === "provider.completion.cancelled", - )?.[0].properties, - ).toMatchObject({ childId: "cancelled", error: { message: "user cancelled review" } }); - } finally { - emitted.mockRestore(); - } -}); - -test("SSE provider failures preserve actual reasons and upstream identity without credential echoes", async () => { - const { getLogger } = await import("@logtape/logtape"); - const { spyOn } = await import("bun:test"); - const { anthropicMessagesV4, openaiResponsesV3 } = await import("./streaming-profiles.ts"); - const emitted = spyOn(getLogger(["labkit", "provider"]), "emit"); - try { - for (const [profile, events, reason] of [ - [ - anthropicMessagesV4, - [ - { - type: "error", - error: { type: "overloaded_error", message: "Overloaded for private-stream-key" }, - }, - ], - "overloaded_error", - ], - [ - openaiResponsesV3, - [ - { type: "response.created", response: { id: "r" } }, - { - type: "response.failed", - response: { - id: "r", - status: "failed", - error: { code: "server_error", message: "retry private-stream-key" }, - }, - }, - ], - "server_error", - ], - [ - openaiResponsesV3, - [ - { type: "response.created", response: { id: "r" } }, - { - type: "response.incomplete", - response: { - id: "r", - status: "incomplete", - incomplete_details: { reason: "max_output_tokens" }, - }, - }, - ], - "max_output_tokens", - ], - ] as const) { - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - headers: { "x-api-key": "private-stream-key" }, - fetch: (async () => - new Response( - events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), - { - headers: { - "content-type": "text/event-stream", - "request-id": "upstream-stream", - }, - }, - )) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - const failure = await port - .complete( - { ...prepared, provider: profile.id, stream: true }, - new AbortController().signal, - undefined, - undefined, - { childId: reason }, - ) - .catch((error) => error); - expect(failure.message).toContain(reason); - expect(failure.message).not.toContain("private-stream-key"); - const record = emitted.mock.calls.find( - ([record]) => - record.rawMessage === "provider.stream.failed" && record.properties.childId === reason, - )?.[0].properties; - expect(record).toMatchObject({ providerRequestId: "upstream-stream", childId: reason }); - expect(JSON.stringify(record)).toContain(reason); - } - expect(JSON.stringify(emitted.mock.calls)).not.toContain("private-stream-key"); - } finally { - emitted.mockRestore(); - } -}); - -test("stream summaries and completion diagnostics carry final Chat and Anthropic output usage", async () => { - const { getLogger } = await import("@logtape/logtape"); - const { spyOn } = await import("bun:test"); - const { anthropicMessagesV4, openaiChatV2 } = await import("./streaming-profiles.ts"); - const emitted = spyOn(getLogger(["labkit", "provider"]), "emit"); - try { - for (const [profile, events, expectedUsage] of [ - [ - openaiChatV2, - [ - { choices: [{ index: 0, delta: { content: "answer" }, finish_reason: "stop" }] }, - { choices: [], usage: { completion_tokens: 19 } }, - "[DONE]", - ], - { completion_tokens: 19 }, - ], - [ - anthropicMessagesV4, - [ - { - type: "message_start", - message: { - role: "assistant", - content: [], - usage: { input_tokens: 11, output_tokens: 1 }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "answer" }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { output_tokens: 23 }, - }, - { type: "message_stop" }, - ], - { input_tokens: 11, output_tokens: 23 }, - ], - ] as const) { - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - new Response( - events - .map( - (event) => - `data: ${typeof event === "string" ? event : JSON.stringify(event)}\n\n`, - ) - .join(""), - { headers: { "content-type": "text/event-stream" } }, - )) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await port.complete( - { ...prepared, provider: profile.id, stream: true }, - new AbortController().signal, - ); - for (const event of ["provider.stream.completed", "provider.completion.completed"]) - expect( - emitted.mock.calls.find( - ([record]) => record.rawMessage === event && record.properties.provider === profile.id, - )?.[0].properties, - ).toMatchObject({ usage: expectedUsage, durationMs: expect.any(Number) }); - } - } finally { - emitted.mockRestore(); - } -}); - -test("rejected Chat and Google stream finish reasons remain diagnosable", async () => { - const { getLogger } = await import("@logtape/logtape"); - const { spyOn } = await import("bun:test"); - const { openaiChatV2, googleGenerateV3 } = await import("./streaming-profiles.ts"); - const emitted = spyOn(getLogger(["labkit", "provider"]), "emit"); - try { - for (const [profile, frame, reason] of [ - [ - openaiChatV2, - { - choices: [{ index: 0, delta: {}, finish_reason: "length" }], - usage: { completion_tokens: 4096 }, - }, - "length", - ], - [ - googleGenerateV3, - { - candidates: [{ finishReason: "MAX_TOKENS" }], - usageMetadata: { candidatesTokenCount: 4096 }, - }, - "MAX_TOKENS", - ], - ] as const) { - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - new Response(`data: ${JSON.stringify(frame)}\n\n`, { - headers: { "content-type": "text/event-stream" }, - })) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - { ...prepared, provider: profile.id, stream: true, maxOutputTokens: 4096 }, - new AbortController().signal, - ), - ).rejects.toThrow(); - const record = emitted.mock.calls.find( - ([record]) => - record.rawMessage === "provider.stream.failed" && - record.properties.provider === profile.id, - )?.[0].properties; - expect(record).toMatchObject({ finishReasons: [reason], maxOutputTokens: 4096 }); - expect(JSON.stringify(record?.usage)).toContain("4096"); - } - } finally { - emitted.mockRestore(); - } -}); - -test("terminal stream evidence survives a following ping before premature EOF", async () => { - const { getLogger } = await import("@logtape/logtape"); - const { spyOn } = await import("bun:test"); - const { anthropicMessagesV4 } = await import("./streaming-profiles.ts"); - const emitted = spyOn(getLogger(["labkit", "provider"]), "emit"); - try { - const events = [ - { - type: "message_start", - message: { role: "assistant", content: [], usage: { input_tokens: 11 } }, - }, - { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 23 } }, - { type: "ping" }, - ]; - const profile = anthropicMessagesV4; - const port = bindProviders( - new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => - new Response( - events - .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) - .join(""), - { headers: { "content-type": "text/event-stream" } }, - )) as unknown as typeof fetch, - }, - }, - ], - ]), - ); - await expect( - port.complete( - { ...prepared, provider: profile.id, stream: true }, - new AbortController().signal, - ), - ).rejects.toThrow("Incomplete completion stream"); - const record = emitted.mock.calls.find( - ([record]) => record.rawMessage === "provider.stream.failed", - )?.[0].properties; - expect(record).toMatchObject({ - lastEvent: "ping", - stopReason: "end_turn", - usage: { input_tokens: 11, output_tokens: 23 }, - }); - } finally { - emitted.mockRestore(); - } -}); - -test("cancellation during request capture cannot dispatch HTTP afterward", async () => { - const captured = deferred(); - const release = deferred(); - const controller = new AbortController(); - let calls = 0; - const transport = httpTransport({ - baseUrl: "https://example.invalid", - capture: async (event) => { - if (event.kind === "http_request") { - captured.resolve(); - await release.promise; - } - }, - fetch: (async () => { - calls++; - return Response.json({}); - }) as unknown as typeof fetch, - }); - const request = transport( - { path: "/complete", method: "POST", headers: {}, body: {} }, - controller.signal, - ); - const rejected = request.catch((error: unknown) => error); - await captured.promise; - controller.abort(new Error("Cancelled during capture")); - release.resolve(); - expect(await rejected).toMatchObject({ message: "Cancelled during capture" }); - expect(calls).toBe(0); -}); diff --git a/packages/core-agent/providers/transport.ts b/packages/core-agent/providers/transport.ts deleted file mode 100644 index fc3b6ded..00000000 --- a/packages/core-agent/providers/transport.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { z } from "zod"; - -import type { PreparedModel } from "../agent/agent.ts"; -import { - MediaKindSchema, - type BlobResolver, - type MediaKind, - type MediaSupport, -} from "../agent/content.ts"; -import { CompletionSchema } from "../agent/types.ts"; -import { diagnosticsSubscriber, fanoutEffects, type EffectEmitter } from "../effects/index.ts"; -import { freeze } from "../fsm/fsm.ts"; -import { diagnosticError, redactDiagnostics } from "../logging/index.ts"; -import { HANDOFF_TOOL } from "./shared.ts"; -import { assembleStream } from "./stream.ts"; -import { - parseRequest, - ProviderSettingsSchema, - validateProviderSettings, - type CompletionProfile, - type DecodedCompletion, - type HttpRequest, - type HttpResponse, - type StreamAssembler, - type StreamDeltaSink, - type validateThinking, -} from "./types.ts"; -import { CompletionUsageSchema } from "./usage.ts"; - -export type ProviderDiagnosticContext = Readonly<{ - sessionId?: string; - turnId?: string; - childId?: string; - generation?: number; - requestId?: string; - httpRequestId?: string; - provider?: string; - model?: string; - toolCallId?: string; -}>; - -export type ProviderCapture = ( - event: Readonly<{ - kind: "http_request" | "http_response" | "completion"; - httpRequestId: string; - [key: string]: unknown; - }>, -) => void | Promise; - -/** - * Independent, frozen copy of a value about to be handed to an effect subscriber. Some payloads - * (for example `bindProviders(...).complete`'s `trace`) are still-mutable local state at the time - * they are first emitted; without this, a subscriber that freezes what it receives, or simply - * inspects it later, would see a different, or throw on, a value the runtime goes on to mutate. - */ -function snapshot(value: T): T { - return freeze(structuredClone(value)); -} - -/** Numeric usage and terminal reasons only; never copy generated content. */ -function responseEvidence(body: unknown): Record { - if (!body || typeof body !== "object") return {}; - const value = body as Record; - const numeric = (item: unknown): unknown => { - if (!item || typeof item !== "object") return undefined; - return Object.fromEntries(Object.entries(item).filter(([, n]) => typeof n === "number")); - }; - const entries = Array.isArray(value.choices) - ? value.choices - : Array.isArray(value.candidates) - ? value.candidates - : []; - return { - stopReason: value.stop_reason ?? value.status, - finishReasons: entries.map((entry) => entry?.finish_reason ?? entry?.finishReason), - usage: numeric(value.usage ?? value.usageMetadata), - }; -} - -/** Origin/version prefix and credentials are environment-owned, never journaled. */ -export type TransportBinding = Readonly<{ - baseUrl: string; - headers?: Readonly>; - fetch?: typeof fetch; - capture?: ProviderCapture; - /** Supply unique IDs for retained HTTP evidence; defaults to crypto.randomUUID. */ - requestId?: () => string; - /** Subscriber for this provider's HTTP/stream effect events; always logged, additionally routed here. */ - effects?: EffectEmitter; -}>; - -export function httpTransport(binding: TransportBinding, legacyChatErrors = false) { - const base = z - .url({ protocol: /^https?$/ }) - .parse(binding.baseUrl) - .replace(/\/+$/, ""); - const url = new URL(base); - if (url.username || url.password || url.search || url.hash) - throw new Error("Transport base URL must not contain credentials, query, or fragment"); - const headers = { ...binding.headers }; - const fetcher = binding.fetch ?? fetch; - const captureSink = binding.capture; - const emit = fanoutEffects(diagnosticsSubscriber(), binding.effects); - const requestId = binding.requestId ?? (() => crypto.randomUUID()); - // Remove actual configured credentials even when a provider echoes them in prose. - const secrets = Object.entries(headers) - .filter(([name]) => /authorization|api[-_]?key|token|secret|cookie/i.test(name)) - .flatMap(([, value]) => [value, value.replace(/^(Bearer|Basic)\s+/i, "")]) - .filter(Boolean); - const redact = (text: string) => - secrets.reduce((value, secret) => value.split(secret).join("[REDACTED]"), text); - return async ( - request: HttpRequest, - signal: AbortSignal, - streaming?: { assembler: StreamAssembler; sink?: StreamDeltaSink }, - context: ProviderDiagnosticContext = {}, - ): Promise => { - const started = performance.now(); - const trace = { - ...context, - httpRequestId: context.httpRequestId ?? requestId(), - endpoint: - base + request.path + (request.query ? `?${new URLSearchParams(request.query)}` : ""), - method: request.method, - streaming: !!streaming, - }; - const capture = async ( - kind: "http_request" | "http_response", - fields: Record, - ) => { - if (captureSink) - await captureSink( - redactDiagnostics( - { kind, ...trace, ...fields }, - secrets, - ) as Parameters[0], - ); - }; - let responseBody = ""; - let responseMetadata: Record = {}; - let phase = "encode"; - try { - signal.throwIfAborted(); - if ( - !request.path.startsWith("/") || - request.path.startsWith("//") || - /[?#]/.test(request.path) || - request.path.split("/").includes("..") - ) - throw new Error("Profile endpoint must be a relative API path"); - const query = request.query ? `?${new URLSearchParams(request.query)}` : ""; - await capture("http_request", { - body: redact(JSON.stringify(request.body)), - endpoint: base + request.path + query, - }); - signal.throwIfAborted(); - phase = "fetch"; - emit({ type: "provider.http.started", trace }); - const response = await fetcher(base + request.path + query, { - method: request.method, - ...(legacyChatErrors ? {} : { redirect: "error" as const }), - headers: { "Content-Type": "application/json", ...request.headers, ...headers }, - body: JSON.stringify(request.body), - signal, - }); - const providerRequestId = - response.headers.get("request-id") ?? - response.headers.get("x-request-id") ?? - response.headers.get("x-goog-request-id"); - const evidence = { - ...trace, - httpStatus: response.status, - providerRequestId, - durationMs: Math.round(performance.now() - started), - }; - responseMetadata = evidence; - emit({ type: "provider.http.received", evidence }); - signal.throwIfAborted(); - if (!response.ok) { - phase = "http_error"; - const errorBody = redact(await response.text()); - responseBody = errorBody; - emit({ type: "provider.http.rejected", evidence, errorBody }); - throw Object.assign( - new Error( - `${legacyChatErrors ? "OpenAI-compatible API request failed" : "Completion HTTP failure"} (${response.status})${providerRequestId ? ` [request ${providerRequestId}]` : ""}: ${errorBody}`, - ), - { httpStatus: response.status, providerRequestId, errorBody }, - ); - } - phase = streaming ? "stream" : "response_json"; - if (streaming) { - const body = await assembleStream( - response, - streaming.assembler, - signal, - streaming.sink, - evidence, - secrets, - emit, - async (chunk) => { - responseBody += chunk; - await capture("http_response", { - ...evidence, - body: responseBody, - phase, - outcome: "in_progress", - }); - }, - ); - await capture("http_response", { - ...evidence, - body: responseBody, - phase, - ...responseEvidence(body), - outcome: "received", - }); - emit({ - type: "provider.http.completed", - evidence, - durationMs: Math.round(performance.now() - started), - }); - return { status: response.status, headers: response.headers, body }; - } - let body: unknown; - try { - responseBody = await response.text(); - body = JSON.parse(responseBody); - } catch (cause) { - signal.throwIfAborted(); - throw new Error("Completion response is not valid JSON", { cause }); - } - await capture("http_response", { - ...evidence, - body: responseBody, - phase, - ...responseEvidence(body), - }); - signal.throwIfAborted(); - emit({ - type: "provider.http.completed", - evidence, - durationMs: Math.round(performance.now() - started), - }); - return { status: response.status, headers: response.headers, body }; - } catch (error) { - const safeError = diagnosticError(error, secrets); - await capture("http_response", { - ...responseMetadata, - body: responseBody, - phase, - error: safeError, - }); - emit( - signal.aborted - ? { - type: "provider.http.cancelled", - trace, - phase, - durationMs: Math.round(performance.now() - started), - error: safeError, - } - : { - type: "provider.http.failed", - trace, - phase, - durationMs: Math.round(performance.now() - started), - error: safeError, - }, - ); - throw Object.assign( - new Error(String(safeError.message ?? "Provider request failed"), { cause: safeError }), - { - ...safeError, - httpStatus: responseMetadata.httpStatus, - providerRequestId: responseMetadata.providerRequestId, - phase, - }, - ); - } - }; -} - -export function canonicalRequest(prepared: PreparedModel) { - return parseRequest( - { - provider: prepared.provider, - model: prepared.model, - messages: prepared.messages.map((message) => { - if (message.role === "tool") - return { - role: "tool", - text: message.content, - callId: message.tool_call_id, - ...(message.parts ? { parts: message.parts } : {}), - }; - if (message.role === "assistant" && message.tool_calls) - return { - role: "assistant", - text: message.content, - ...(message.parts ? { parts: message.parts } : {}), - ...(message.owner ? { owner: message.owner } : {}), - calls: message.tool_calls.map((call) => ({ - id: call.id, - name: call.function.name, - args: JSON.parse(call.function.arguments), - })), - }; - return { - role: message.role, - text: message.content, - ...(message.parts ? { parts: message.parts } : {}), - ...(message.role === "assistant" && message.owner ? { owner: message.owner } : {}), - }; - }), - continuations: prepared.continuations, - tools: (prepared.tools ?? []).map((tool) => tool.function), - temperature: prepared.temperature, - thinking: prepared.thinking, - thinkingBudgetTokens: prepared.thinkingBudgetTokens, - stream: prepared.stream, - maxOutputTokens: prepared.maxOutputTokens, - successors: prepared.successors ?? [], - }, - undefined, - true, - ); -} - -/** - * Resolves a profile's binary `media` list (what the wire format can encode) into a three-valued - * capability per media kind (D3): a kind the profile declares is `supported` — a bound profile is - * an authored declaration that its provider's models handle that kind, not a guess — and a kind it - * does not declare is `unsupported`. `unknown` is part of the type (a projection target may be - * built with it, and the projection renders it exactly like `unsupported`: a pointer) for a target - * whose capabilities genuinely cannot be confirmed, which `describe()` does not currently produce - * because the committed catalog snapshot carries no per-model modality data; a future catalog - * enrichment can set it explicitly without changing this function's contract. A plain record, not - * a closure: prompt input crosses `structuredClone` boundaries in the host. - */ -export function mediaCapability( - profileMedia: readonly MediaKind[], -): Readonly> { - return Object.fromEntries( - MediaKindSchema.options.map((kind) => [ - kind, - profileMedia.includes(kind) ? "supported" : "unsupported", - ]), - ) as Readonly>; -} - -export type ResolvedModel = Readonly<{ - provider: string; - model: string; - wireModel: string; - profile: string; - capabilities: Readonly<{ - thinking: CompletionProfile["capabilities"]["thinking"]; - outputTokens?: CompletionProfile["capabilities"]["outputTokens"]; - stream: boolean; - /** Three-valued per media kind; see {@link mediaCapability}. */ - media: Readonly>; - }>; -}>; - -export type ProviderBindings = ReadonlyMap< - string, - Readonly<{ - profile: CompletionProfile; - transport: TransportBinding; - models?: ReadonlyMap>; - }> ->; -/** Copy binding identities/settings once. Code and credentials never enter restored state. */ -export function bindProviders(bindings: ProviderBindings) { - const bound = new Map( - [...bindings].map(([id, binding]) => { - const secrets = Object.entries(binding.transport.headers ?? {}) - .filter(([name]) => /authorization|api[-_]?key|token|secret|cookie/i.test(name)) - .flatMap(([, value]) => [value, value.replace(/^(Bearer|Basic)\s+/i, "")]) - .filter(Boolean); - const captureSink = binding.transport.capture; - const capture: ProviderCapture | undefined = captureSink - ? (event) => - captureSink(redactDiagnostics(event, secrets) as Parameters[0]) - : undefined; - - return [ - id, - { - profile: Object.freeze({ - ...binding.profile, - capabilities: freeze(structuredClone(binding.profile.capabilities)), - }), - http: httpTransport(binding.transport), - requestId: binding.transport.requestId ?? (() => crypto.randomUUID()), - capture, - emit: fanoutEffects(diagnosticsSubscriber(), binding.transport.effects), - secrets, - models: binding.models - ? new Map( - [...binding.models].map(([name, model]) => [ - name, - { - wireModel: model.wireModel, - profile: Object.freeze({ - ...model.profile, - capabilities: freeze(structuredClone(model.profile.capabilities)), - }), - }, - ]), - ) - : undefined, - }, - ] as const; - }), - ); - return Object.freeze({ - ids: Object.freeze([...bound.keys()]), - describe: (provider: string, model: string): ResolvedModel | undefined => { - const binding = bound.get(provider); - if (!binding) return undefined; - const selected = binding.models?.get(model); - if (binding.models && !selected) return undefined; - const profile = selected?.profile ?? binding.profile; - return freeze({ - provider, - model, - wireModel: selected?.wireModel ?? model, - profile: profile.id, - capabilities: { - thinking: profile.capabilities.thinking, - ...(profile.capabilities.outputTokens - ? { outputTokens: profile.capabilities.outputTokens } - : {}), - stream: profile.capabilities.stream, - media: mediaCapability(profile.capabilities.media), - }, - }); - }, - validateSelection: ( - provider: string, - model: string | undefined, - thinking: Parameters[0], - stream?: boolean, - thinkingBudgetTokens?: number | null, - maxOutputTokens?: number, - ) => { - const binding = bound.get(provider); - if (!binding) - throw new Error(`Unknown provider ${provider}; supported: ${[...bound.keys()].join(", ")}`); - const selected = binding.models?.get(model ?? ""); - if (binding.models && !selected) - throw new Error( - `Unknown model ${model ?? "(missing)"} for ${provider}; supported: ${[...binding.models.keys()].join(", ")}`, - ); - const profile = selected?.profile ?? binding.profile; - validateProviderSettings( - { thinking, thinkingBudgetTokens, maxOutputTokens }, - profile.capabilities, - ); - if (stream && (!profile.capabilities.stream || !profile.stream)) - throw new Error( - `Unsupported streaming for model ${model ?? "(default)"}; use stream:false`, - ); - }, - streams: new Map( - [...bound].map(([id, binding]) => [ - id, - binding.profile.capabilities.stream && !!binding.profile.stream, - ]), - ), - capabilities: new Map( - [...bound].map(([id, binding]) => [ - id, - freeze(structuredClone(binding.profile.capabilities.thinking)), - ]), - ), - complete: async ( - request: PreparedModel, - signal: AbortSignal, - blobs?: BlobResolver, - onDelta?: StreamDeltaSink, - context: ProviderDiagnosticContext = {}, - ): Promise => { - const started = performance.now(); - const trace = { - ...context, - httpRequestId: - context.httpRequestId ?? - (bound.get(request.provider ?? "")?.requestId ?? (() => crypto.randomUUID()))(), - provider: request.provider, - model: request.model, - stream: request.stream ?? false, - thinking: request.thinking, - thinkingBudgetTokens: request.thinkingBudgetTokens, - maxOutputTokens: request.maxOutputTokens, - messageCount: request.messages.length, - toolCount: request.tools?.length ?? 0, - }; - let phase = "validate"; - let terminalEvidence: Record = {}; - const emit = bound.get(request.provider ?? "")?.emit ?? diagnosticsSubscriber(); - emit({ type: "provider.completion.started", trace: snapshot(trace) }); - try { - if (!request.provider) throw new Error("Prepared request has no provider"); - ProviderSettingsSchema.parse({ - provider: request.provider, - thinking: request.thinking, - thinkingBudgetTokens: request.thinkingBudgetTokens, - stream: request.stream, - maxOutputTokens: request.maxOutputTokens, - }); - const binding = bound.get(request.provider); - if (!binding) throw new Error("Missing versioned provider binding"); - const selected = binding.models?.get(request.model); - if (binding.models && !selected) - throw new Error( - `Unknown model ${request.model}; supported: ${[...binding.models.keys()].join(", ")}`, - ); - const profile = selected?.profile ?? binding.profile; - Object.assign(trace, { - profile: profile.id, - wireModel: selected?.wireModel ?? request.model, - }); - validateProviderSettings(request, profile.capabilities); - if (request.stream && (!profile.capabilities.stream || !profile.stream)) - throw new Error("Unsupported streaming setting"); - const input = canonicalRequest(request); - const wireInput = { - ...input, - provider: profile.id, - model: selected?.wireModel ?? input.model, - continuations: input.continuations?.map((entry) => ({ ...entry, provider: profile.id })), - }; - phase = "encode"; - const encoded = profile.encode(wireInput, blobs); - phase = "transport"; - const response = await binding.http( - encoded, - signal, - request.stream ? { assembler: profile.stream!(), sink: onDelta } : undefined, - trace, - ); - terminalEvidence = { - httpStatus: response.status, - providerRequestId: - response.headers.get("request-id") ?? response.headers.get("x-request-id"), - ...responseEvidence(response.body), - }; - phase = "decode"; - const decoded = profile.decode(response, wireInput); - const result = CompletionSchema.parse(decoded.completion); - const decodedUsage = decoded.usage; - const providerRequestId = - response.headers.get("request-id") ?? response.headers.get("x-request-id"); - const usage = - decodedUsage === undefined - ? undefined - : CompletionUsageSchema.parse( - redactDiagnostics( - { - ...decodedUsage, - source: { - provider: request.provider, - model: request.model, - wireModel: selected?.wireModel ?? request.model, - profile: profile.id, - httpRequestId: trace.httpRequestId, - ...(providerRequestId ? { providerRequestId } : {}), - }, - }, - binding.secrets, - ), - ); - if (usage?.status === "invalid") - emit({ - type: "provider.usage.invalid", - trace: snapshot(trace), - terminalEvidence: snapshot(terminalEvidence), - error: usage.error, - }); - if (result.kind === "handoff" && !input.successors.includes(result.agent)) - throw new Error("Unpermitted handoff target"); - if (result.kind === "tools" && result.calls.some((call) => call.name === HANDOFF_TOOL)) - throw new Error("Reserved handoff tool cannot be executed"); - const completion = freeze({ - completion: result, - ...(usage ? { usage } : {}), - ...(decoded.continuationPayload === undefined - ? {} - : { continuationPayload: z.json().parse(decoded.continuationPayload) }), - }); - await bound.get(request.provider!)?.capture?.({ - kind: "completion", - ...trace, - ...terminalEvidence, - phase, - outcome: "completed", - }); - emit({ - type: "provider.completion.completed", - trace: snapshot(trace), - terminalEvidence: snapshot(terminalEvidence), - durationMs: Math.round(performance.now() - started), - completionKind: result.kind, - continuation: decoded.continuationPayload !== undefined, - }); - return completion; - } catch (error) { - const safeError = diagnosticError(error, bound.get(request.provider ?? "")?.secrets); - await bound.get(request.provider ?? "")?.capture?.({ - kind: "completion", - ...trace, - ...terminalEvidence, - phase, - outcome: signal.aborted ? "cancelled" : "failed", - error: safeError, - }); - emit( - signal.aborted - ? { - type: "provider.completion.cancelled", - trace: snapshot(trace), - terminalEvidence: snapshot(terminalEvidence), - phase, - durationMs: Math.round(performance.now() - started), - error: diagnosticError(error), - } - : { - type: "provider.completion.failed", - trace: snapshot(trace), - terminalEvidence: snapshot(terminalEvidence), - phase, - durationMs: Math.round(performance.now() - started), - error: diagnosticError(error), - }, - ); - throw Object.assign( - new Error(String(safeError.message ?? "Completion failed"), { cause: safeError }), - { - phase, - ...terminalEvidence, - ...(safeError.providerStop === undefined - ? {} - : { providerStop: safeError.providerStop }), - }, - ); - } - }, - }); -} diff --git a/packages/core-agent/providers/types.ts b/packages/core-agent/providers/types.ts deleted file mode 100644 index ad5d5217..00000000 --- a/packages/core-agent/providers/types.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { z } from "zod"; - -import { BlobRefSchema, type BlobResolver, type MediaKind } from "../agent/content.ts"; -import { CompletionOwnerSchema, MessagesSchema, ToolNameSchema } from "../agent/types.ts"; -import { freeze } from "../fsm/fsm.ts"; -import type { CompletionUsage } from "./usage.ts"; - -export const ThinkingSchema = z.enum(["off", "low", "medium", "high", "adaptive", "budget"]); - -export type ThinkingCapability = - | Readonly<{ mode: "off" }> - | Readonly<{ mode: "effort"; values: readonly ("none" | "low" | "medium" | "high")[] }> - | Readonly<{ mode: "budget"; minTokens: number; maxTokens?: number }> - | Readonly<{ mode: "adaptive" }>; - -export const ContinuationPayloadSchema = z - .json() - .refine((payload) => JSON.stringify(payload).length <= 65536, "Continuation exceeds 64 KiB"); - -export const ContinuationSchema = z - .strictObject({ - provider: z.string().min(1), - owner: CompletionOwnerSchema, - payload: ContinuationPayloadSchema.optional(), - payloadBlob: BlobRefSchema.refine( - (ref) => ref.media === "text/plain", - "Continuation blobs must be JSON text", - ).optional(), - }) - .refine( - (entry) => (entry.payload !== undefined) !== (entry.payloadBlob !== undefined), - "Exactly one continuation payload representation is required", - ) - .readonly(); - -export type Continuation = z.infer; - -export type DecodedCompletion = Readonly<{ - completion: unknown; - continuationPayload?: unknown; - usage?: CompletionUsage; -}>; - -export function validateThinking( - thinking: z.infer | undefined, - capability: ThinkingCapability, - thinkingBudgetTokens?: number | null, - maxOutputTokens?: number, -) { - if (thinking !== "budget" && thinkingBudgetTokens != null) - throw new Error( - "thinkingBudgetTokens requires thinking: budget; clear the budget when changing thinking mode", - ); - if (thinking === "budget" && capability.mode === "budget") { - if ( - thinkingBudgetTokens == null || - !Number.isSafeInteger(thinkingBudgetTokens) || - thinkingBudgetTokens < capability.minTokens || - (capability.maxTokens !== undefined && thinkingBudgetTokens > capability.maxTokens) - ) - throw new Error( - `Set thinkingBudgetTokens explicitly between ${capability.minTokens} and ${capability.maxTokens ?? "the model's supported maximum"}; no thinking budget is chosen automatically`, - ); - if (maxOutputTokens === undefined || maxOutputTokens <= thinkingBudgetTokens) - throw new Error( - `maxOutputTokens must exceed thinkingBudgetTokens (${thinkingBudgetTokens}) to leave room for the answer`, - ); - } - if (thinking === undefined || thinking === "off") return; - if ( - thinking === "adaptive" - ? capability.mode === "adaptive" - : thinking === "budget" - ? capability.mode === "budget" - : capability.mode === "effort" && capability.values.includes(thinking) - ) - return; - throw new Error( - `Unsupported thinking setting ${thinking}; supported: off${capability.mode === "off" ? "" : capability.mode === "effort" ? `, ${capability.values.join(", ")}` : capability.mode === "budget" ? `, budget (explicit thinkingBudgetTokens >= ${capability.minTokens})` : ", adaptive"}`, - ); -} - -export function validateProviderSettings( - request: Pick, - capabilities: CompletionProfile["capabilities"], -) { - validateThinking( - request.thinking, - capabilities.thinking, - request.thinkingBudgetTokens, - request.maxOutputTokens, - ); - if (capabilities.outputTokens?.required && request.maxOutputTokens === undefined) - throw new Error( - "Set maxOutputTokens explicitly for this model; no output cap is chosen automatically", - ); - if ( - capabilities.outputTokens?.maxTokens !== undefined && - request.maxOutputTokens !== undefined && - request.maxOutputTokens > capabilities.outputTokens.maxTokens - ) - throw new Error( - `maxOutputTokens exceeds this model's declared limit (${capabilities.outputTokens.maxTokens})`, - ); -} - -export function matchingContinuations( - messages: readonly { role: string; owner?: z.infer }[], - continuations: readonly Continuation[], - provider?: string, -) { - return continuations.filter( - (entry) => - entry.provider === provider && - messages.some( - (message) => - message.role === "assistant" && - message.owner?.turnId === entry.owner.turnId && - message.owner.generation === entry.owner.generation, - ), - ); -} -/** Data only. Settings are captured with each prepared request. */ -export const ProviderSettingsSchema = z - .strictObject({ - provider: z.string().min(1), - thinking: ThinkingSchema.optional(), - thinkingBudgetTokens: z.number().int().positive().nullable().optional(), - stream: z.boolean().optional(), - maxOutputTokens: z.number().int().positive().optional(), - }) - .readonly(); - -export type ProviderSettings = z.infer; - -export const ToolAdvertisementSchema = z - .strictObject({ - name: ToolNameSchema, - description: z.string().optional(), - parameters: z.record(z.string(), z.json()), - }) - .readonly(); - -export const CompletionRequestSchema = z - .strictObject({ - provider: ProviderSettingsSchema.unwrap().shape.provider.optional(), - model: z.string().min(1), - messages: MessagesSchema, - continuations: z.array(ContinuationSchema).readonly().optional(), - tools: z.array(ToolAdvertisementSchema).readonly(), - thinking: ThinkingSchema.optional(), - thinkingBudgetTokens: z.number().int().positive().nullable().optional(), - stream: z.boolean().optional(), - temperature: z.number().finite().optional(), - maxOutputTokens: z.number().int().positive().optional(), - successors: z.array(z.string().min(1)).readonly(), - }) - .readonly(); - -export type CompletionRequest = z.infer; -/** Relative endpoint: no origin or credentials are visible to profiles. */ -export type HttpRequest = Readonly<{ - path: string; - query?: Readonly>; - method: "POST"; - headers: Readonly>; - body: unknown; -}>; - -export type HttpResponse = Readonly<{ status: number; headers: Headers; body: unknown }>; - -export const StreamDeltaSchema = z - .strictObject({ - text: z.string().optional(), - thinking: z.string().optional(), - usage: z.record(z.string(), z.json()).readonly().optional(), - }) - .readonly(); - -export type StreamDelta = z.infer; - -export type StreamDeltaSink = (delta: StreamDelta) => unknown; - -export type StreamEvent = Readonly<{ event?: string; data: string }>; -/** One assembler per operation; finish rejects incomplete protocol sequences. */ -export type StreamAssembler = { - push(event: StreamEvent): readonly StreamDelta[]; - finish(): unknown; -}; - -export type CompletionProfile = Readonly<{ - id: string; - capabilities: Readonly<{ - thinking: ThinkingCapability; - outputTokens?: Readonly<{ required: boolean; maxTokens?: number }>; - stream: boolean; - media: readonly MediaKind[]; - }>; - encode(request: CompletionRequest, blobs?: BlobResolver): HttpRequest; - stream?: () => StreamAssembler; - decode(response: HttpResponse, request?: CompletionRequest): DecodedCompletion; -}>; - -export function parseRequest( - raw: unknown, - profileId?: string, - supportsStream = false, -): CompletionRequest { - const request = freeze(CompletionRequestSchema.parse(raw)); - if (request.stream && !supportsStream) throw new Error("Unsupported streaming setting"); - const owners = request.messages.flatMap((message) => - message.role === "assistant" && message.owner ? [JSON.stringify(message.owner)] : [], - ); - if (new Set(owners).size !== owners.length) throw new Error("Duplicate assistant owner"); - const continuationOwners = (request.continuations ?? []).map((entry) => - JSON.stringify(entry.owner), - ); - if (new Set(continuationOwners).size !== continuationOwners.length) - throw new Error("Duplicate continuation owner"); - if (profileId !== undefined && request.provider !== undefined && request.provider !== profileId) - throw new Error("Request provider does not match profile"); - const provider = profileId ?? request.provider; - if ((request.continuations ?? []).some((entry) => entry.provider !== provider)) - throw new Error("Continuation provider does not match request provider"); - if ( - matchingContinuations(request.messages, request.continuations ?? [], provider).length !== - continuationOwners.length - ) - throw new Error("Continuation owner has no assistant message"); - const pending = new Set(); - let conversationStarted = false; - for (const message of request.messages) { - if (message.role === "system") { - if (conversationStarted) - throw new Error("System messages must precede conversation messages"); - continue; - } - conversationStarted = true; - if (message.role === "tool") { - if (!pending.delete(message.callId)) throw new Error("Uncorrelated tool result"); - } else { - if (pending.size) throw new Error("Missing tool results"); - if (message.role === "assistant") - for (const call of message.calls ?? []) pending.add(call.id); - } - } - if (pending.size) throw new Error("Missing tool results"); - return request; -} diff --git a/packages/core-agent/providers/usage.test.ts b/packages/core-agent/providers/usage.test.ts deleted file mode 100644 index 63e23f39..00000000 --- a/packages/core-agent/providers/usage.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { decodeUsage } from "./usage.ts"; - -test("token accounting includes Anthropic cache input without double counting OpenAI or Google cache", () => { - expect( - decodeUsage( - { - usage: { - input_tokens: 10, - cache_read_input_tokens: 100, - cache_creation_input_tokens: 20, - output_tokens: 30, - cache_creation: { ephemeral_5m_input_tokens: 20 }, - }, - }, - "anthropic", - ), - ).toMatchObject({ - inputTokens: 130, - outputTokens: 30, - totalTokens: 160, - native: { cache_creation: { ephemeral_5m_input_tokens: 20 } }, - }); - expect( - decodeUsage( - { - usage: { - prompt_tokens: 100, - completion_tokens: 30, - total_tokens: 130, - prompt_tokens_details: { cached_tokens: 80 }, - completion_tokens_details: { reasoning_tokens: 10 }, - }, - }, - "chat", - ), - ).toMatchObject({ inputTokens: 100, outputTokens: 30, totalTokens: 130 }); - expect( - decodeUsage( - { - usage: { - input_tokens: 100, - output_tokens: 30, - total_tokens: 130, - input_tokens_details: { cached_tokens: 80 }, - }, - }, - "responses", - ), - ).toMatchObject({ inputTokens: 100, outputTokens: 30, totalTokens: 130 }); - expect( - decodeUsage( - { - usageMetadata: { - promptTokenCount: 100, - cachedContentTokenCount: 80, - candidatesTokenCount: 20, - thoughtsTokenCount: 10, - totalTokenCount: 130, - }, - }, - "google", - ), - ).toMatchObject({ inputTokens: 100, outputTokens: 30, totalTokens: 130 }); -}); - -test("missing counts stay unknown; malformed or overflowing counts remain explicitly invalid", () => { - expect(decodeUsage({}, "chat")).toBeUndefined(); - expect(decodeUsage({ usage: { output_tokens: 3 } }, "anthropic")).toEqual({ - status: "reported", - outputTokens: 3, - native: { output_tokens: 3 }, - }); - for (const input_tokens of [-1, 0.5, "10", Number.MAX_SAFE_INTEGER + 1]) - expect(decodeUsage({ usage: { input_tokens } }, "anthropic")).toMatchObject({ - status: "invalid", - error: expect.stringContaining("usage.input_tokens"), - native: { input_tokens }, - }); - expect( - decodeUsage( - { - usage: { - input_tokens: Number.MAX_SAFE_INTEGER, - cache_read_input_tokens: 1, - }, - }, - "anthropic", - ), - ).toMatchObject({ status: "invalid" }); -}); - -test("all streaming dialects expose only their fully assembled response accounting", async () => { - const { PreparedModelSchema } = await import("../agent/agent.ts"); - const { bindProviders } = await import("./transport.ts"); - const { streamingProfiles, streamResponse, streamVector } = await import( - "./testing/stream-vectors.ts" - ); - for (const profile of streamingProfiles) { - const frames = streamVector(profile); - const makePort = (truncated: boolean) => - bindProviders( - new Map([ - [ - "selected", - { - profile, - transport: { - baseUrl: "https://scripted.invalid", - fetch: (async (_url: Parameters[0]) => - streamResponse(truncated ? frames.slice(0, -1) : frames)) as typeof fetch, - }, - }, - ], - ]), - ); - const request = PreparedModelSchema.parse({ - provider: "selected", - model: "deployment", - maxOutputTokens: 16384, - stream: true, - messages: [{ role: "user", content: "Read the supplied document" }], - successors: [], - }); - const response = await makePort(false).complete(request, new AbortController().signal); - expect(response.usage).toMatchObject({ - outputTokens: 4, - source: { - provider: "selected", - model: "deployment", - wireModel: "deployment", - profile: profile.id, - }, - }); - await expect(makePort(true).complete(request, new AbortController().signal)).rejects.toThrow(); - } -}); diff --git a/packages/core-agent/providers/usage.ts b/packages/core-agent/providers/usage.ts deleted file mode 100644 index fa9cfa3e..00000000 --- a/packages/core-agent/providers/usage.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { z } from "zod"; - -const tokens = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); - -const source = z - .strictObject({ - provider: z.string().min(1), - model: z.string().min(1), - wireModel: z.string().min(1), - profile: z.string().min(1), - httpRequestId: z.string().min(1), - providerRequestId: z.string().min(1).optional(), - }) - .readonly(); - -/** Per-response accounting. These values are not a current-context estimate or a bill. */ -export const CompletionUsageSchema = z.discriminatedUnion("status", [ - z - .strictObject({ - status: z.literal("reported"), - inputTokens: tokens.optional(), - outputTokens: tokens.optional(), - totalTokens: tokens.optional(), - native: z.record(z.string(), z.json()).readonly(), - source: source.optional(), - }) - .readonly(), - z - .strictObject({ - status: z.literal("invalid"), - native: z.json(), - error: z.string().min(1), - source: source.optional(), - }) - .readonly(), -]); - -export type CompletionUsage = z.infer; - -export function decodeUsage( - body: unknown, - dialect: "anthropic" | "chat" | "responses" | "google", -): CompletionUsage | undefined { - const response = z.record(z.string(), z.unknown()).parse(body); - const value = response[dialect === "google" ? "usageMetadata" : "usage"]; - if (value == null) return undefined; - try { - return normalizeUsage(value, dialect); - } catch (cause) { - return CompletionUsageSchema.parse({ - status: "invalid", - native: value, - error: cause instanceof Error ? cause.message : String(cause), - }); - } -} - -function normalizeUsage( - value: unknown, - dialect: "anthropic" | "chat" | "responses" | "google", -): CompletionUsage { - const native = z.record(z.string(), z.json()).parse(value); - const count = (key: string) => { - if (native[key] === undefined) return undefined; - const parsed = tokens.safeParse(native[key]); - if (!parsed.success) - throw new Error( - `Invalid ${dialect} usage.${key}: expected a nonnegative safe integer, received ${JSON.stringify(native[key])}`, - { cause: parsed.error }, - ); - return parsed.data; - }; - - let inputTokens: number | undefined; - let outputTokens: number | undefined; - let totalTokens: number | undefined; - if (dialect === "anthropic") { - const uncached = count("input_tokens"); - const read = count("cache_read_input_tokens"); - const written = count("cache_creation_input_tokens"); - inputTokens = uncached === undefined ? undefined : uncached + (read ?? 0) + (written ?? 0); - outputTokens = count("output_tokens"); - totalTokens = - inputTokens === undefined || outputTokens === undefined - ? undefined - : inputTokens + outputTokens; - } else if (dialect === "google") { - inputTokens = count("promptTokenCount"); - const candidates = count("candidatesTokenCount"); - const thoughts = count("thoughtsTokenCount"); - outputTokens = candidates === undefined ? undefined : candidates + (thoughts ?? 0); - totalTokens = count("totalTokenCount"); - } else { - inputTokens = count(dialect === "chat" ? "prompt_tokens" : "input_tokens"); - outputTokens = count(dialect === "chat" ? "completion_tokens" : "output_tokens"); - totalTokens = count("total_tokens"); - } - return CompletionUsageSchema.parse({ - status: "reported", - ...(inputTokens === undefined ? {} : { inputTokens }), - ...(outputTokens === undefined ? {} : { outputTokens }), - ...(totalTokens === undefined ? {} : { totalTokens }), - native, - }); -} diff --git a/packages/core-agent/session/README.md b/packages/core-agent/session/README.md deleted file mode 100644 index 8d4a732b..00000000 --- a/packages/core-agent/session/README.md +++ /dev/null @@ -1,349 +0,0 @@ -# Integrating a session - -Use a session when your application needs to explain what happened after a disconnect, crash, or -storage failure. The session records accepted work before starting it and records results before -using them. This gives you a durable account of execution; it cannot make a remote API call or file -write transactional with the journal. - -The caller supplies configuration, executable bindings, and persistence separately. Configuration -expresses what the session may do. Bindings supply how it does it: tools, provider credentials, -permission UI, and observers. Keeping bindings out of saved state lets you reopen a session without -serializing credentials or resurrecting old network connections. - -## Submit work and interpret the result - -```ts -import { createSession } from "@labkit/core-agent"; -import { createMemoryPersistence } from "@labkit/core-agent/testing"; - -const session = await createSession({ - persistence: createMemoryPersistence(), // Process-local demonstration, not durable storage. - configuration: { - agent: "reviewer", - agents: new Map([["reviewer", { model: "scripted" }]]), - steps: 4, - }, - bindings: { - complete: () => ({ completion: { kind: "answer", text: "Review complete" } }), - }, -}); -const command = session.input("Review this result."); -const receipt = await command.accepted; -const result = await command.settled; -if (result.kind === "terminal") { - const outcome = result.record.outcome; - if (outcome.kind === "failed") console.error(outcome.error); -} else { - console.error(result.kind, result.message); // Storage/admission failure or session close. -} -await session.close(); -``` - -There are three different observations; choose the one that answers your application's question: - -| Observation | What you can conclude | What to do with it | -| ---------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------ | -| Successful `accepted` receipt | The input was committed. | Show it as accepted; keep listening for cancellation and output. | -| Tool/stream notification | Execution has made visible progress. | Update the UI. Do not start dependent work from it. | -| `settled`, with `kind: "terminal"` | The turn's outcome was committed. | Inspect `record.outcome`; a settled turn may have failed, aborted, or exhausted its steps. | - -Inspect the receipt's discriminant: a resolved promise alone does not mean admission succeeded. -Malformed public input can throw before admission. A storage failure or close can settle a command -without a terminal record. Failed receipts and settlements carry the same structured `error`, -including storage operation identity, original cause, and reconciliation evidence. `message` is its -human-readable summary. `snapshot.durable` is committed state; `pending.next` is only a proposal -waiting for storage. Do not persist pending state as a successful result. - -The host owns the completion/tool loop. Your completion binding returns an answer, tool calls, or a -handoff; it must not execute the returned tool calls itself. Run the -[completion binding example](examples/completion-binding.ts) to see the exact messages, argument -strings, tool results, and response envelope at this boundary: - -```sh -bun packages/core-agent/session/examples/completion-binding.ts -``` - -## Change the next turn, not an operation in flight - -Call `updatePolicy(patch)` to change model, thinking, streaming, allowed tools, permission mode, or -limits. It selects at once, at any time: the result is stored in the persistence port's -configuration store and applied as a "configuration applied" `policy` record at the next boundary -between turns, before any queued input starts. It never answers `busy`. The receipt says which: -`accepted` (idle; applied now), `selected` (a turn is running; applies when it ends), `ignored` -(matches the policy in force), or `failed`. A new input submitted at an idle boundary waits for a -pending selection to apply first. `selectedPolicy` shows what the next turn runs under; `policy` -shows what is in force. A selection pending when the process stopped applies when the session is -restored, after recovery and registry adoption. Applying a changed permission mode or tool scope -revokes remembered `allow-session` approvals; re-selecting the same mode changes nothing. - -Call `updateSystem(inputs)` to replace session instructions. It requires an idle boundary with no -accepted queued inputs; check for a `busy` receipt, wait for the current work to settle, then -commit the change before submitting the next input. - -Both rules keep a single turn from starting under one permission/model configuration and finishing -under another. Mutating the original options map does not reconfigure an open session: bindings -are captured at construction. `session.model` exposes the resolved selection and capabilities; -[provider bindings](../providers/README.md) explain how to declare them. - -The default input policy allows replacement while a step's prompt is projected or its completion is -in flight (including a handoff successor's first step) and rejects -input during tools or permission waiting. Replacement joins the active turn; both callers receive -that turn's eventual terminal record. Choose a [queue policy](../policy/README.md) if every accepted -input must become a separate turn. Abort does not discard already accepted queued successors. - -## Permission requests - -Set `permissions: "ask"` and supply `bindings.requestPermission`. Each call in a batch is decided -independently, in order. `allow-session` remembers approval for the named tool and all its -arguments until this live session closes or tool scope changes or permissions are explicitly reset; -`allow-once` covers only this call. A refusal produces a permission-refused tool result for that -call only; approved calls in the same batch run normally, and the turn continues to the next step -so the model can ask the user what to do instead. An `allow-session` grant from the same batch -installs even alongside a refused sibling call; only a cancelled dialog or a permission port -failure discards uncommitted grants. A cancelled dialog stops asking about the rest of -the batch and aborts the turn. Without the binding, this policy is rejected before execution. - -Use `bindings.toolUpdate` for display only. A pending card is not a permission request, and a completed -card is not a durable result. The authoritative permission response and journal receipt both precede -execution. See the [host port contract](../host/README.md#permission-and-display-ports). - -## Decide what to do after work stops - -A failed operation carries a serializable `error`: classification, message, operation identity, -phase, and available original cause details. Tool name/call ID and provider status/request ID survive -when known. Use these fields to explain the failure; do not parse English messages or search the -journal to decide whether a user refused permission. - -| Outcome | Meaning for the caller | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| Failed, `timeout` | The configured completion/tool deadline expired. The signal was aborted; an external effect may already exist. | -| Aborted, with reason | Cancellation ended the turn. Inspect the reason for its initiating operation. | -| Failed, `interrupted` after restore | Work was active when the saved execution stopped. Its external result may be unknown. | -| Exhausted | The turn used its allowed model steps. This is a limit, not a provider failure. | - -A refused tool call does not fail the turn: it settles as a tool record whose raw result carries -classification `permission_refused`, converted to a permission-refused result for the model. Read -that classification on the tool record to distinguish a refusal from an ordinary tool failure; do -not parse English messages. - -`completionTimeoutMs` and `toolTimeoutMs` are optional policy limits in positive integer milliseconds -(maximum 2,147,483,647); null or omission disables them. Permission waiting is untimed. Deadlines -are captured when an operation starts. Under `return-error-and-continue`, an expired tool deadline -becomes an error tool result (classification `timeout`) that the model reads on its next step, so -the turn continues; under `fail-turn` it fails the turn. A completion deadline always fails the -turn. Neither causes an automatic retry. Cancellation rejects late results locally; it cannot undo -a write or force an uncooperative tool to stop. Tool implementations should honor their AbortSignal. - -## Inspect response accounting - -`session.lastCompletionUsage` exposes `{ turnId, operationId, usage }` for the latest committed -completion that supplied accounting. Read `usage.status` before using counters: `reported` contains -validated known counters and native fields; `invalid` contains the original accounting and its -validation error. Both keep HTTP provenance when supplied by provider bindings. This is immutable -public state; consumers do not need to search journal records. Pending receipts, partial streams, -failed operations and cancellation cannot publish a new value. Restore reconstructs it without -calling the provider. A newly created fork or compaction has no completion of its own to report. - -The value describes an observed response, not the current projected prompt or cumulative cost. -It remains historical when configuration or input changes. Missing accounting leaves the last -observed record available with its original operation ID. `completion.usage.received` and -`completion.usage.committed` distinguish observed from persisted evidence in launcher logs. - -## What the journal records - -The journal holds facts, not projections. Every record is format `version: 2`; a version 1 journal -(written before prompts stopped being journaled) does not load and has no migration. - -| Record | What it states | -| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `created` | The session's seed: registry, instructions, initial policy, inherited context and log. The step allowance is the policy's `steps`. | -| `event` | User input, abort, a branch request, or a child operation settling (`model_settled`, `permission_settled`, `batch_settled`, `failed`). | -| `model_settled` | A step's model output (or failure), the provider and model it was made with, and any continuation. No prompt, no usage, no permission flag. | -| `tool` | One tool call's raw result, committed before its batch settles. | -| `batch_settled` | How the batch settled (`succeeded`, `failed` with error, `cancelled`). Its results are the batch's `tool` records. | -| `effect` (`usage`) | The accounting a completion reported, committed in the same append after its `model_settled`. Nothing in the fold depends on it. | -| `terminal` | The turn-ended fact: the turn's final agent and outcome, in the append that ended the turn. The turn's messages come from the fold. | -| `policy`, `system`, `configuration`, `queued`, `dequeued`, `input_cancelled`, `recovery` | Configuration boundaries, queued input, and closing an interrupted turn. | - -A step's prompt is a projection of these facts, recomputed when the step runs and never stored. So -is a handoff's packet: the successor's first step renders it from the policy's handoff resolver and -the turn's messages up to the handoff. Whether a step needs permission is derived from the policy in -force, on staging and on load alike. - -## Reopen without repeating effects - -Save the session ID and call `restoreSession(options, sessionId)` with the current agents, tools and -bindings. Code is not journaled: replacing a tool implementation under the same schema is your -responsibility. - -Loading a journal checks its integrity only: each record decodes, batches continue at their expected -revision, revisions run 1, 2, 3…, append IDs are unique and entry IDs are `/`, every -record belongs to the session, the first record (and only the first) creates it, and a turn's -terminal record follows the record that ended the turn in the same batch. A record must also name a -turn, operation, tool batch, call or queued input that exists in the folded state. An effect record -has no such precondition: its turn and operation IDs correlate it, and it loads whatever state the -conversation is in. Commit-time rules (configuration validation, permissions, admission, bindings) -run only when new work is staged, never again on load. No prompt is stored. A configuration-applied -record's policy, and a terminal record's agent and outcome, are facts and are taken as written, so a -later change to how a build validates settings or words an outcome never makes an old session -unloadable. - -A load failure is a `JournalIntegrityError` with `rule`, `revision`, `appendId` and `entryId` of the -offending record (for an undecodable record, where it should be). `session.restore_failed` reports -the same fields at stage `replay_journal`. A record with a newer `version` or a `kind` this build -does not know fails `record_decode` with a message that names the version or kind and says the -journal was probably written by a newer Labkit build, so the launcher should be restarted on current -code; an older `version` fails `record_decode` naming the older format and saying to start a new -session. The schema error stays as the `cause`. - -`restoreSession` rejects with `SessionNotFoundError` (carrying `sessionId`) when the store holds no -journal under that ID, so hosts can tell a missing or deleted session from a failed load. - -Neither a changed agent/tool registry, a provider/model that is no longer bound, nor a policy -pack, projection or handoff the live environment no longer supplies prevents reopening. -`session.registry` is `{ kind: "pending_adoption", differences }` until the first input, system or -policy change, fork or compaction; that work first commits a `configuration` record holding the -live registry, then runs. When needed the record also carries a new policy version and switches an -unregistered current agent to `configuration.agent`. The new policy drops unregistered tools and -removed agents from tool permissions. A saved pack `id`, `project` or `handoff` the live resolvers -lack takes the value a new session would get. Provider settings the live bindings reject (provider, -model, thinking, budget, stream, output limit, permission mode) are replaced by the defaults a new -session would get, changing as few as possible; every other setting is kept. `session.policy` is -the policy the next turn uses. History is never filtered: the next prompt includes earlier calls to -removed tools and advertises only live tools. Opening writes nothing else; -`session.registry.mismatch`, `.reconciled` (a warning for a model, agent, projection or handoff -change), `.adopted` and `.adoption_failed` explain each step. A failed adoption -append fails the session and the waiting work with its storage cause. New configuration selections -are still validated against live bindings. See [registry adoption](../../../docs/session-runtime.md#restore-adopts-the-live-registry-and-bindings). - -Restore invokes no completion, tool, or permission callback. An interrupted turn is closed with an -explicit recovery failure; committed partial tool results survive. Accepted queued inputs are -cancelled rather than started. This deliberate stopping rule avoids repeating an external write -whose acknowledgement was lost. To repeat an action, submit a new input explicitly after inspecting -the outcome. Tools do not need to be idempotent for recovery to be safe from automatic replay. - -`close()` cancels owned work and settles callers, but leaves the caller's persistence store open. -A dispatched append may still commit after close. Restore to discover its result; do not treat close -as rollback. See [receipt and recovery diagrams](../../../docs/session-runtime.md). - -## Supply persistence with the required guarantees - -Implement `SessionPersistence` and run its -[contract suite](testing/persistence-contract.ts). An append must atomically accept the complete -batch at its expected revision. Its stable append ID must recognize identical retries and reject -changed bytes. A lost acknowledgement is `indeterminate`, not proof of failure. `getConfig` and -`putConfig` hold the session's selected configuration outside the journal: the latest selection per -session, written before the session applies it. - -Persistence adapters can return a serializable `error` alongside a failed/rejected/indeterminate -message to preserve database codes and nested causes. Thrown exceptions are captured by the storage -operation boundary. A reconciliation failure also retains the preceding uncertain append failure, -so the public result explains both the lost acknowledgement and why execution could not continue. - -The session reconciles uncertainty with a consistent load before releasing work. If the append is -absent at the expected revision, it may retry the _same storage append_ once. This never retries the -completion or tool. A store that can commit an old append after reporting it absent violates the -contract and can cause execution to proceed from false evidence. Add crash tests for your real -storage engine; the memory adapter only tests the protocol. - -All new records use one current format (`version: 2`), independently of configuration revisions or -features. Historical formats are not supported. See [journal and blob storage](../../../docs/session-persistence.md) -for bytes, hashing, limits, and persistence lifetime. - -## Branches and attachments - -`fork()` waits for the active turn's terminal boundary and creates an independent session with its -history. `compact(messages)` creates a child whose context is your replacement and whose prior turn -log is empty. It does not ask a model to summarize. Neither changes the parent or carries live work -into the child. Await publication before using the child: its seed and referenced blobs must exist. -Parent and child writes are separate transactions; interrupted publication can leave a saved child. -Use the child ID recorded in the parent request to restore it instead of creating another branch. - -Store attachment bytes with `persistence.putBlob` before submitting their refs via -`input({ text, attachments })`. A successful restore proves the journal is intact, not that all blobs -are still available: bytes are checked when needed for a new completion. Missing bytes fail before -HTTP. Unsupported or unconfirmed media (D3) never fails: the target-aware prompt projection sends a -pointer instead of the blob, and only the blobs the projected request keeps are loaded. Forks copy -referenced blobs; compaction copies only replacement context refs. Continuation payloads are -provider-owned context, retained for matching assistant messages; compaction drops them. -Applications should not manufacture or edit their contents. - -## Diagnose and verify an integration - -Configure [environment logging](../logging/README.md) before creating sessions. Follow a terminal -failure's session/turn/operation IDs to `child.failed` and provider records for stack and transport -details. For exact request/response bodies, bind [provider capture](../environment/README.md#retained-provider-traffic); -`completion.system_prompt` logs the full ordered system messages at DEBUG before each completion, -with session, turn, agent, model, and operation IDs. The readable journal includes configured agent -prompts and the committed shared-instruction history. User/file contents and full HTTP bodies remain -in the journal or provider capture. Core owns neither sink nor retention. - -```sh -LOGTAPE_TEST_MODE=always LOGTAPE_TEST_LOWEST_LEVEL=debug bun test packages/core-agent/session/consumer-contract.test.ts -bun run packages/core-agent/session/fixture-runner.ts -bun run packages/core-agent/session/fixture-runner.ts --v2 -``` - -The inspector prints a unique run directory with linked journals and `diagnostics.log` / `.jsonl`. -Default runs cover all scenarios; `--v2` selects the policy group, not a different journal format. -Consumer tests retain actual scripted HTTP traffic under `.session-artifacts/consumer*/` and -`.session-artifacts/peer-review/`. Read the [fixture guide](fixtures/README.md) before updating -baselines. Tests use scripted responses and establish no live-provider or disk-durability guarantee. - -## Project one history for every consumer - -`projectConversation(state.durable)` (in `views.ts`, re-exported from the session entry point) -turns folded state into the one `ConversationView` every consumer renders from: inherited context, -settled turns and the live turn's messages so far, each message reduced to its role, text, typed -blob refs (`id`, `media`, `bytes`, optional `name`, and a resolvable `blob://.` `uri`), -tool calls, and the call a tool result answers. `projectMessage`/`projectTurn` project one message -or turn. It is a pure projection: never stored, never compared with a stored copy. ACP `session/load` -replay and the web console's turn-log and live-message arrays are built from it instead of each -deriving history separately. Incremental streaming text (ACP's `agent_message_chunk` deltas, the -web console's `delta` events) still reads its text straight off the completion event as it arrives; -a partial chunk is not yet a message to project. - -`journalMarkdown(state.durable)` (re-exported here from `journal/render.ts`) renders folded facts — -context, turn log, the unfinished turn, permission decisions, recoveries — through the same -`projectMessage`, so its Markdown never surfaces a raw journal record; blob attachments render as -their `blob://` URI. `journalJSONL(state.durable)` is different: it dumps every stored record -verbatim, one per line; since the journal holds only facts, that includes no prompt. Both back -`/export`: the web -server exposes `GET /api/session/:id/export.md` and `.jsonl`, and the ACP adapter's built-in -`/export` slash command writes the Markdown form under `/.labkit/exports/.md` -without calling the model. - -## Journal modules - -`session-log.ts` is the public barrel. It also defines `toSeed` itself, so tests can spy on it -through the `session-log` namespace. The implementation lives in `journal/`: - -- `state.ts`: `JournalState` and the other journal types, the `Fold` mode and the `Reducer` type. -- `reduce.ts`: the typed reducer table (one reducer per journal body kind) and `reduce`, which - applies the entry guard before dispatch. The reducers live in `reduce-boundary.ts` (policy, - configuration, system), `reduce-queue.ts` (queued, input_cancelled, dequeued), `reduce-tools.ts` - (tool, recovery) and `reduce-event.ts` (event, with `domain-event.ts`, and the usage effect). - `shared.ts` holds their common helpers. -- `stage.ts`: commit time. It applies every commit-time rule before a record is written. -- `replay.ts`: load time. It folds stored records and checks journal integrity only - (`JournalIntegrityError`). -- `seed.ts`: creation seeds. `codec.ts`: record encoding and decoding. `render.ts`: the JSONL and - Markdown journal views. - -## Runtime modules - -`session-runtime.ts` holds the public types and re-exports the entry points from `runtime/`: - -- `open.ts`: `createSession`, `restoreSession` and `SessionNotFoundError`. -- `configure.ts`: `configureSession` validates options and captures the bindings a session and its - fork/compact children share; `bind-options.ts` resolves the completion port and policy resolvers. -- `registry-adoption.ts`: plans the `configuration` record that journals live registry drift on - restore. -- `instance.ts`: `SessionInstance`, the per-session state every handler receives explicitly, and - `openInstance`/`initializeSession`. -- `session-commands.ts`, `conversation-effects.ts`, `env-events.ts`: one handler per session actor - command, conversation command and public event. -- `facade.ts`: the `SessionRuntime` object over a `SessionInstance`. - -`openInstance` builds the state maps first, then the host, then the session actor, then the facade. -The host and actor callbacks read the instance lazily, so none of them runs during construction. diff --git a/packages/core-agent/session/attachments-runtime.test.ts b/packages/core-agent/session/attachments-runtime.test.ts deleted file mode 100644 index 383956c9..00000000 --- a/packages/core-agent/session/attachments-runtime.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { BlobRefSchema, hashBlob } from "../agent/content.ts"; -import { anthropicMessagesV2, openaiChat, type CompletionProfile } from "../providers/index.ts"; -import { decodeRecord, journalJSONL, replay } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deferred, deterministicIds, testOptions, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -function setup(profile: CompletionProfile = openaiChat) { - const bodies: any[] = []; - const port = createMemoryPersistence(); - let reads = 0; - const opts: SessionOptions = { - persistence: { - ...port, - getBlob: async (...args) => { - reads++; - return port.getBlob(...args); - }, - }, - configuration: { - agent: "a", - agents: new Map([["a", { model: "fixture-model", tools: [], successors: [] }]]), - steps: 3, - policy: { - maxOutputTokens: 16384, - provider: profile.id, - ...(profile === anthropicMessagesV2 - ? { thinking: "budget", thinkingBudgetTokens: 1024, maxOutputTokens: 2048 } - : {}), - }, - }, - bindings: { - id: deterministicIds(), - providers: new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - return Response.json( - profile === anthropicMessagesV2 - ? { - role: "assistant", - stop_reason: "end_turn", - content: [ - { type: "thinking", thinking: "private", signature: "sig" }, - { type: "text", text: "done" }, - ], - } - : { choices: [{ message: { content: "done" } }] }, - ); - }) as typeof fetch, - }, - }, - ], - ]), - }, - }; - return { opts, bodies, reads: () => reads }; -} - -const signal = () => new AbortController().signal; - -const bytes = new TextEncoder().encode("# DESIGN\nAttachment bytes stay outside the journal."); - -test("markdown refs commit without bytes, restore does not read blobs, fork and compact copy cited refs", async () => { - const { opts, bodies, reads } = setup(); - const session = await createSession(opts); - const id = session.snapshot.durable.conversation.sessionId; - const ref = await opts.persistence.putBlob( - id, - bytes, - { media: "text/markdown", name: "DESIGN.md" }, - signal(), - ); - const unused = await opts.persistence.putBlob( - id, - new Uint8Array([9]), - { media: "image/png" }, - signal(), - ); - const result = await session.input({ text: "Review", attachments: [ref] }).settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(bodies[0].messages).toContainEqual({ - role: "user", - content: `Review\n${new TextDecoder().decode(bytes)}`, - }); - expect(journalJSONL(session.snapshot.durable)).toContain(ref.id); - expect(journalJSONL(session.snapshot.durable)).not.toContain("Attachment bytes stay"); - const v5 = session.snapshot.durable.records.find( - (r) => r.body.kind === "event" && r.body.event.type === "user", - )!; - expect(v5.version).toBe(2); - expect(() => decodeRecord(JSON.stringify({ ...v5, version: 4 }))).toThrow(); - const before = reads(); - const restored = await restoreSession(opts, id); - expect(reads()).toBe(before); - expect(bodies).toHaveLength(1); - const fork = await session.fork(); - expect( - await opts.persistence.getBlob(fork.snapshot.durable.conversation.sessionId, ref.id, signal()), - ).toEqual({ meta: ref, bytes }); - await fork.input("Next").settled; - expect(JSON.stringify(bodies.at(-1))).toContain("Attachment bytes stay"); - const compact = await session.compact([ - { role: "user", text: "", parts: [{ type: "blob", ref }] }, - ]); - const compactId = compact.snapshot.durable.conversation.sessionId; - expect(await opts.persistence.getBlob(compactId, ref.id, signal())).toEqual({ meta: ref, bytes }); - expect(await opts.persistence.getBlob(compactId, unused.id, signal())).toEqual({ - kind: "not_found", - }); - const empty = await session.compact([]); - expect( - await opts.persistence.getBlob(empty.snapshot.durable.conversation.sessionId, ref.id, signal()), - ).toEqual({ kind: "not_found" }); - expect(await opts.persistence.getBlob(id, unused.id, signal())).not.toEqual({ - kind: "not_found", - }); - await compact.input("Review compacted").settled; - expect(JSON.stringify(bodies.at(-1))).toContain("Attachment bytes stay"); - const reopened = await restoreSession(opts, id); - const continued = await reopened.input("Restored next").settled; - expect(continued.kind === "terminal" && continued.record.outcome.kind).toBe("completed"); - expect(JSON.stringify(bodies.at(-1))).toContain("Attachment bytes stay"); - await Promise.all([session, restored, reopened, fork, compact, empty].map((s) => s.close())); -}); - -test("missing attachment fails the step without HTTP", async () => { - const { opts, bodies } = setup(); - const session = await createSession(opts); - const ref = BlobRefSchema.parse({ - id: hashBlob(bytes), - bytes: bytes.length, - media: "text/plain", - }); - const result = await session.input({ attachments: [ref] }).settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("failed"); - expect(bodies).toHaveLength(0); - const settled = session.snapshot.durable.records.find( - (r) => - r.body.kind === "event" && - r.body.event.type === "child" && - r.body.event.event.type === "model_settled", - ); - expect(settled).toMatchObject({ body: { event: { event: { result: { kind: "failed" } } } } }); - await session.close(); -}); - -test("thinking and image attachments retain independent owners and refs across turns", async () => { - const { opts, bodies } = setup(anthropicMessagesV2); - const session = await createSession(opts); - const ref = await opts.persistence.putBlob( - session.snapshot.durable.conversation.sessionId, - new Uint8Array([1, 2, 3]), - { media: "image/png" }, - signal(), - ); - await session.input({ attachments: [ref] }).settled; - await session.input("Next").settled; - expect(bodies[0].messages[0].content).toEqual([ - { type: "image", source: { type: "base64", media_type: "image/png", data: "AQID" } }, - ]); - expect(bodies[1].messages[1].content[0]).toEqual({ - type: "thinking", - thinking: "private", - signature: "sig", - }); - const [user, assistant] = session.snapshot.durable.conversation.log[0]!.messages; - expect(user).toMatchObject({ role: "user", parts: [{ type: "blob", ref }] }); - expect(assistant).not.toHaveProperty("parts"); - expect(assistant).toHaveProperty("owner"); - const loaded = await opts.persistence.load( - session.snapshot.durable.conversation.sessionId, - signal(), - ); - if (loaded.kind !== "loaded") throw new Error("Missing journal"); - expect(replay(loaded.batches)).toEqual(session.snapshot.durable); - const restored = await restoreSession(opts, session.snapshot.durable.conversation.sessionId); - await Promise.all([session.close(), restored.close()]); -}); - -test("queued attachment input retains refs until its turn", async () => { - const { opts, bodies } = setup(); - const release = deferred(); - const profileBinding = opts.bindings.providers!.get(openaiChat.id)!; - let started = false; - const session = await createSession({ - ...opts, - configuration: { - ...opts.configuration, - policy: { ...opts.configuration.policy, id: "queued@1" }, - }, - bindings: { - ...opts.bindings, - providers: new Map([ - [ - openaiChat.id, - { - ...profileBinding, - transport: { - ...profileBinding.transport, - fetch: (async (...args: Parameters) => { - if (!started) { - started = true; - return release.promise; - } - return profileBinding.transport.fetch!(...args); - }) as typeof fetch, - }, - }, - ], - ]), - }, - }); - const ref = await opts.persistence.putBlob( - session.snapshot.durable.conversation.sessionId, - bytes, - { media: "text/markdown" }, - signal(), - ); - const first = session.input("First"); - await until(() => started); - const queued = session.input({ attachments: [ref] }); - expect((await queued.accepted).kind).toBe("accepted"); - release.resolve(Response.json({ choices: [{ message: { content: "done" } }] })); - await first.settled; - const result = await queued.settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(JSON.stringify(bodies.at(-1))).toContain("Attachment bytes stay"); - const restored = await restoreSession(opts, session.snapshot.durable.conversation.sessionId); - await Promise.all([session.close(), restored.close()]); -}); - -test("abort during a blob read cancels the step and prevents late HTTP", async () => { - const { opts, bodies } = setup(); - const backing = opts.persistence; - const release = deferred(); - let readSignal: AbortSignal | undefined; - const session = await createSession({ - ...opts, - persistence: { - ...backing, - async getBlob(sessionId, id, signal) { - readSignal = signal; - await release.promise; - return backing.getBlob(sessionId, id, new AbortController().signal); - }, - }, - }); - const ref = await backing.putBlob( - session.snapshot.durable.conversation.sessionId, - bytes, - { media: "text/markdown" }, - signal(), - ); - const handle = session.input({ attachments: [ref] }); - await until(() => readSignal !== undefined); - await session.fire({ type: "abort" }); - expect(readSignal!.aborted).toBe(true); - release.resolve(); - const outcome = await handle.settled; - expect(outcome.kind === "terminal" && outcome.record.outcome.kind).toBe("aborted"); - await session.close(); - expect(bodies).toHaveLength(0); -}); - -test("current format validation covers created and compact context refs", async () => { - const { opts } = setup(); - const session = await createSession(opts); - const ref = await opts.persistence.putBlob( - session.snapshot.durable.conversation.sessionId, - bytes, - { media: "text/plain" }, - signal(), - ); - await session.input({ attachments: [ref] }).settled; - const child = await session.compact([{ role: "user", text: "", parts: [{ type: "blob", ref }] }]); - for (const entry of [...session.snapshot.durable.records, ...child.snapshot.durable.records]) { - if (JSON.stringify(entry.body).includes(ref.id)) - expect(() => decodeRecord(JSON.stringify({ ...entry, version: 4 }))).toThrow(); - } - await Promise.all([session.close(), child.close()]); -}); - -test("attachment admission retains the single format without rewriting prior records", async () => { - const opts = testOptions(); - const session = await createSession(opts); - const old = journalJSONL(session.snapshot.durable); - const ref = await opts.persistence.putBlob( - session.snapshot.durable.conversation.sessionId, - bytes, - { media: "text/plain" }, - signal(), - ); - await session.input({ attachments: [ref] }).settled; - expect(journalJSONL(session.snapshot.durable).startsWith(old)).toBe(true); - expect(session.snapshot.durable.records.slice(0, 3).map((r) => r.version)).toEqual([2, 2, 2]); - const restored = await restoreSession(opts, session.snapshot.durable.conversation.sessionId); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); - await Promise.all([session.close(), restored.close()]); -}); - -test("attachment queued while aborting tools keeps both records in v5", async () => { - const { opts } = setup(anthropicMessagesV2); - let toolStarted = false; - let attempts = 0; - const binding = opts.bindings.providers!.get(anthropicMessagesV2.id)!; - const configured: SessionOptions = { - ...opts, - configuration: { - ...opts.configuration, - agents: new Map([["a", { model: "fixture-model", tools: ["wait"], successors: [] }]]), - policy: { ...opts.configuration.policy, admission: "abort-tools-on-user" }, - }, - bindings: { - ...opts.bindings, - tools: new Map([ - [ - "wait", - defineTool({ - input: z.object({}), - run: (_, signal) => { - toolStarted = true; - return new Promise((_, reject) => - signal.addEventListener("abort", () => reject(new Error("cancelled")), { - once: true, - }), - ); - }, - }), - ], - ]), - providers: new Map([ - [ - anthropicMessagesV2.id, - { - ...binding, - transport: { - ...binding.transport, - fetch: (async () => - Response.json({ - role: "assistant", - stop_reason: attempts === 0 ? "tool_use" : "end_turn", - content: [ - attempts++ === 0 - ? { type: "tool_use", id: "call", name: "wait", input: {} } - : { type: "text", text: "done" }, - ], - })) as unknown as typeof fetch, - }, - }, - ], - ]), - }, - }; - const session = await createSession(configured); - const ref = await opts.persistence.putBlob( - session.snapshot.durable.conversation.sessionId, - bytes, - { media: "text/markdown" }, - signal(), - ); - const first = session.input("Wait"); - await until(() => toolStarted); - const next = session.input({ attachments: [ref] }); - const result = await next.settled; - await first.settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - const queued = session.snapshot.durable.records.find((r) => r.body.kind === "queued")!; - const batch = session.snapshot.durable.records.filter((r) => r.appendId === queued.appendId); - expect(batch).toHaveLength(2); - expect(batch.map((r) => r.version)).toEqual([2, 2]); - const restored = await restoreSession( - configured, - session.snapshot.durable.conversation.sessionId, - ); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); - await Promise.all([session.close(), restored.close()]); -}); - -test("public user input requires content, accepts empty attachment lists with text, and rejects raw bytes", async () => { - const { opts } = setup(); - const session = await createSession(opts); - expect(() => session.input({})).toThrow("requires text or attachments"); - expect(() => session.input({ attachments: [] })).toThrow("requires text or attachments"); - expect(() => session.dispatch({ type: "user", attachments: [bytes] })).toThrow(); - const result = await session.input({ text: "Text only", attachments: [] }).settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(session.snapshot.durable.records.every((record) => record.version === 2)).toBe(true); - await session.close(); -}); - -test("Anthropic v2 prepares and encodes PDF as a document block", async () => { - const { opts, bodies } = setup(anthropicMessagesV2); - const session = await createSession(opts); - const pdf = new TextEncoder().encode("%PDF-1.7"); - const ref = await opts.persistence.putBlob( - session.snapshot.durable.conversation.sessionId, - pdf, - { media: "application/pdf" }, - signal(), - ); - const result = await session.input({ attachments: [ref] }).settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(bodies[0].messages[0].content).toEqual([ - { - type: "document", - source: { - type: "base64", - media_type: "application/pdf", - data: Buffer.from(pdf).toString("base64"), - }, - }, - ]); - await session.close(); -}); diff --git a/packages/core-agent/session/blobs.ts b/packages/core-agent/session/blobs.ts deleted file mode 100644 index 93cfb2a6..00000000 --- a/packages/core-agent/session/blobs.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { z } from "zod"; - -import type { PreparedModel } from "../agent/agent.ts"; -import { - blobRefs, - BlobRefSchema, - hashBlob, - type BlobRef, - type BlobResolver, -} from "../agent/content.ts"; -import type { SessionId } from "../agent/types.ts"; -import { ContinuationSchema, type Continuation } from "../providers/types.ts"; -import type { SessionPersistence } from "./persistence.ts"; - -async function readBlob( - port: SessionPersistence, - sessionId: SessionId, - ref: BlobRef, - signal: AbortSignal, -) { - signal.throwIfAborted(); - const loaded = await port.getBlob(sessionId, ref.id, signal); - signal.throwIfAborted(); - if ("kind" in loaded) throw new Error(`Missing attachment blob: ${ref.id}`); - const meta = BlobRefSchema.parse(loaded.meta); - if (!(loaded.bytes instanceof Uint8Array) || loaded.bytes.byteLength !== ref.bytes) - throw new Error(`Attachment byte count mismatch: ${ref.id}`); - const bytes = Uint8Array.from(loaded.bytes); - if ( - meta.id !== ref.id || - meta.media !== ref.media || - meta.bytes !== ref.bytes || - bytes.byteLength !== ref.bytes || - hashBlob(bytes) !== ref.id - ) - throw new Error(`Attachment metadata or hash mismatch: ${ref.id}`); - return { meta, bytes }; -} -/** - * Reads and verifies the blobs a prepared completion request refers to and returns a resolver that - * hands out copies of their bytes. Called inside operation lifetimes only. Replay never reads the - * object store. The target-aware prompt projection already keeps only blob parts the target - * supports, so this loads exactly what the projected request references — no separate media check. - * @param includeContinuations Also read the payload blobs of continuations (provider continuation - * payloads, not the next step). - * @throws when a blob is missing, or its size, media or hash does not match its ref. - */ -export async function resolveRequestBlobs( - port: SessionPersistence, - sessionId: SessionId, - request: PreparedModel, - signal: AbortSignal, - includeContinuations = false, -): Promise { - const refs = blobRefs(request.messages); - if (includeContinuations) refs.push(...continuationBlobRefs(request.continuations ?? [])); - const blobs = new Map(); - for (const ref of refs) { - const existing = blobs.get(ref.id); - if (existing && (existing.ref.media !== ref.media || existing.ref.bytes !== ref.bytes)) - throw new Error("Conflicting attachment metadata"); - if (!existing) - blobs.set(ref.id, { ref, bytes: (await readBlob(port, sessionId, ref, signal)).bytes }); - } - return (id) => { - const blob = blobs.get(id); - if (!blob) throw new Error(`Unresolved attachment blob: ${id}`); - return blob.bytes.slice(); - }; -} -/** - * Copies the blobs a child session (fork or compaction) inherits from its parent. Copy only refs - * inherited by the child, before publishing its creation. - * @throws when a blob is missing or corrupt in the parent, or the child's copy does not match. - */ -export async function copyBranchBlobs( - port: SessionPersistence, - parent: SessionId, - child: SessionId, - refs: readonly BlobRef[], - signal: AbortSignal, -) { - const copied = new Set(); - for (const ref of refs) { - if (copied.has(ref.id)) continue; - const loaded = await readBlob(port, parent, ref, signal); - const meta = await port.putBlob( - child, - loaded.bytes, - { media: loaded.meta.media, ...(loaded.meta.name ? { name: loaded.meta.name } : {}) }, - signal, - ); - signal.throwIfAborted(); - if (meta.id !== ref.id || meta.media !== ref.media || meta.bytes !== ref.bytes) - throw new Error("Branch blob copy mismatch"); - copied.add(ref.id); - } -} - -/** Blob refs of continuations whose payload was stored as a blob rather than inline. */ -export function continuationBlobRefs(entries: readonly Continuation[]) { - return entries.flatMap((entry) => (entry.payloadBlob ? [entry.payloadBlob] : [])); -} -/** - * Builds the continuation (provider continuation payload) to journal with a settled step. A - * payload up to 65,536 characters of JSON stays inline; a larger one is stored as a `text/plain` - * blob and referenced by `payloadBlob`. - * @throws when the payload is not JSON or the stored blob does not match. - */ -export async function storeContinuation( - port: SessionPersistence, - sessionId: SessionId, - entry: { provider: string; owner: Continuation["owner"]; payload: unknown }, - signal: AbortSignal, -): Promise { - signal.throwIfAborted(); - const payload = z.json().parse(entry.payload); - const serialized = JSON.stringify(payload); - if (serialized.length <= 65536) return ContinuationSchema.parse({ ...entry, payload }); - const bytes = new TextEncoder().encode(serialized); - const payloadBlob = await port.putBlob(sessionId, bytes, { media: "text/plain" }, signal); - signal.throwIfAborted(); - if (payloadBlob.id !== hashBlob(bytes) || payloadBlob.bytes !== bytes.length) - throw new Error("Continuation blob write mismatch"); - return ContinuationSchema.parse({ provider: entry.provider, owner: entry.owner, payloadBlob }); -} diff --git a/packages/core-agent/session/consumer-contract.test.ts b/packages/core-agent/session/consumer-contract.test.ts deleted file mode 100644 index 499e546c..00000000 --- a/packages/core-agent/session/consumer-contract.test.ts +++ /dev/null @@ -1,917 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { createProviderCapture } from "../environment/provider-capture.ts"; -import { withFixtureDiagnostics } from "../logging/fixture-capture.ts"; -import { openaiChat, openaiChatV2 } from "../providers/index.ts"; -import { createSession, defineTool, restoreSession, type SessionOptions } from "./index.ts"; -import { deferred, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -const answer = { choices: [{ finish_reason: "stop", message: { content: "extracted" } }] }; - -function options(fetcher: typeof fetch): SessionOptions { - return { - persistence: createMemoryPersistence(), - configuration: { - agent: "reviewer", - agents: new Map([["reviewer", { model: "fast", tools: ["extract"] }]]), - steps: 5, - policy: { maxOutputTokens: 16384, provider: "openai", model: "fast" }, - }, - bindings: { - tools: new Map([ - [ - "extract", - defineTool({ input: z.object({ text: z.string() }), run: ({ text }) => ({ text }) }), - ], - ]), - providers: new Map([ - [ - "openai", - { - profile: openaiChat, - models: new Map([ - ["fast", { wireModel: "wire-fast", profile: openaiChat }], - ["streaming", { wireModel: "wire-stream", profile: openaiChatV2 }], - ]), - transport: { baseUrl: "https://scripted.invalid/v1", fetch: fetcher }, - }, - ], - ]), - }, - }; -} - -test("consumer inspects actual malformed HTTP evidence and terminal cause without journal search", async () => { - const capture = await createProviderCapture(".session-artifacts/consumer"); - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - let invocations = 0; - const opts = options((async (_url: Parameters[0]) => { - invocations++; - return new Response('{"choices":', { headers: { "x-request-id": "broken-json" } }); - }) as typeof fetch); - const binding = opts.bindings.providers!.get("openai")!; - const session = await createSession({ - ...opts, - bindings: { - ...opts.bindings, - providers: new Map([ - ["openai", { ...binding, transport: { ...binding.transport, capture: capture.capture } }], - ]), - }, - }); - try { - const result = await session.input("Extract the claims").settled; - expect(result).toMatchObject({ - kind: "terminal", - record: { - outcome: { - kind: "failed", - error: { - classification: "execution", - operation: { kind: "completion" }, - cause: { message: "Completion response is not valid JSON" }, - }, - }, - }, - }); - await capture.flush(); - const manifest = await Bun.file(`${capture.directory}/manifest.json`).json(); - expect(manifest.calls).toHaveLength(1); - const call = manifest.calls[0]; - expect(call).toMatchObject({ - providerRequestId: "broken-json", - httpStatus: 200, - outcome: "failed", - }); - expect(await Bun.file(`${capture.directory}/${call.responseFile}`).text()).toBe( - '{"choices":', - ); - expect( - JSON.parse(await Bun.file(`${capture.directory}/${call.requestFile}`).text()).model, - ).toBe("wire-fast"); - const restored = await restoreSession(opts, session.snapshot.durable.conversation.sessionId); - expect(invocations).toBe(1); - await restored.close(); - } finally { - await session.close(); - } - }); - const logs = await Bun.file(`${capture.directory}/diagnostics.jsonl`).text(); - expect(logs).toContain('"phase":"response_json"'); - expect(logs).toContain("Completion response is not valid JSON"); -}); - -test("consumer model aliases, extraction tools and explicit repeat preserve configuration boundaries", async () => { - const requests: any[] = []; - let tools = 0; - const opts = options((async (_url, init) => { - const request = JSON.parse(String(init?.body)); - requests.push(request); - return Response.json( - requests.length === 1 - ? { - choices: [ - { - finish_reason: "tool_calls", - message: { - content: "", - tool_calls: [ - { - id: "extract-1", - type: "function", - function: { name: "extract", arguments: '{"text":"claim"}' }, - }, - ], - }, - }, - ], - } - : answer, - ); - }) as typeof fetch); - const session = await createSession({ - ...opts, - bindings: { - ...opts.bindings, - tools: new Map([ - [ - "extract", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => { - tools++; - return { text }; - }, - }), - ], - ]), - }, - }); - try { - expect(await session.input("Review").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - expect(tools).toBe(1); - expect(session.model).toMatchObject({ - provider: "openai", - model: "fast", - wireModel: "wire-fast", - profile: openaiChat.id, - }); - expect(requests[1].messages.at(-1)).toMatchObject({ - role: "tool", - tool_call_id: "extract-1", - content: '{"text":"claim"}', - }); - expect((await session.updatePolicy({ model: "streaming", stream: false })).kind).toBe( - "accepted", - ); - await session.input("Explicit repeat").settled; - expect(requests[2].model).toBe("wire-stream"); - expect(session.model).toMatchObject({ - model: "streaming", - wireModel: "wire-stream", - profile: openaiChatV2.id, - }); - expect(new Set(session.snapshot.durable.records.map((record) => record.version))).toEqual( - new Set([2]), - ); - expect(await session.updatePolicy({ model: "unknown" })).toMatchObject({ - kind: "failed", - message: expect.stringContaining("supported: fast, streaming"), - }); - const restored = await restoreSession(opts, session.snapshot.durable.conversation.sessionId); - expect(requests).toHaveLength(3); - expect(tools).toBe(1); - await restored.close(); - } finally { - await session.close(); - } -}); - -for (const kind of ["completion", "tool"] as const) { - test(`consumer ${kind} deadline aborts its signal, settles once, and restore performs no work`, async () => { - const capture = await createProviderCapture(".session-artifacts/deadlines"); - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - let completions = 0; - let tools = 0; - const late = deferred(); - let signal: AbortSignal | undefined; - const requests: unknown[] = []; - const opts: SessionOptions = { - persistence: createMemoryPersistence(), - configuration: { - agent: "a", - agents: new Map([["a", { model: "m", tools: ["hang"] }]]), - steps: 3, - policy: { - completionTimeoutMs: kind === "completion" ? 20 : null, - toolTimeoutMs: kind === "tool" ? 20 : null, - toolFailure: "return-error-and-continue", - }, - }, - bindings: { - complete: (request, abort) => { - requests.push(request); - completions++; - if (kind === "completion") { - signal = abort; - return late.promise; - } - if (completions === 1) { - return { kind: "tools", text: "", calls: [{ id: "hang-1", name: "hang", args: {} }] }; - } - return { kind: "answer", text: "done" }; - }, - tools: new Map([ - [ - "hang", - defineTool({ - input: z.object({}), - run: (_, abort) => { - tools++; - signal = abort; - return late.promise; - }, - }), - ], - ]), - }, - }; - const session = await createSession(opts); - try { - if (kind === "completion") { - expect(await session.input("Go").settled).toMatchObject({ - record: { - outcome: { - kind: "failed", - error: { classification: "timeout", timeoutMs: 20, operation: { kind } }, - }, - }, - }); - } else { - expect(await session.input("Go").settled).toMatchObject({ - record: { - outcome: { - kind: "completed", - }, - }, - }); - expect(JSON.stringify(requests[1])).toContain("exceeded its 20 ms deadline"); - } - expect(signal?.aborted).toBe(true); - const revision = session.snapshot.durable.revision; - late.resolve(kind === "completion" ? { kind: "answer", text: "late" } : "late"); - await Bun.sleep(0); - expect(session.snapshot.durable.revision).toBe(revision); - if (kind === "completion") { - expect(completions).toBe(1); - expect(tools).toBe(0); - } else { - expect(completions).toBe(2); - expect(tools).toBe(1); - } - const restored = await restoreSession( - opts, - session.snapshot.durable.conversation.sessionId, - ); - if (kind === "completion") { - expect(completions).toBe(1); - } else { - expect(completions).toBe(2); - expect(tools).toBe(1); - } - await restored.close(); - } finally { - await session.close(); - } - }); - expect(await Bun.file(`${capture.directory}/diagnostics.log`).text()).toContain( - "child.timed_out", - ); - }); -} - -test("consumer explicit cancellation is distinguishable from configured timeout", async () => { - let started = false; - const opts = options((async (_url: Parameters[0]) => { - started = true; - return new Promise(() => {}); - }) as typeof fetch); - const session = await createSession(opts); - const turn = session.input("Go"); - await until(() => started); - await session.fire({ type: "abort" }); - expect(await turn.settled).toMatchObject({ - record: { - outcome: { - kind: "aborted", - reason: { classification: "cancelled", operation: { kind: "completion" } }, - }, - }, - }); - await session.close(); -}); - -test("EOF consumer retains partial SSE response, correlation and parser stage", async () => { - const capture = await createProviderCapture(".session-artifacts/consumer-eof"); - const body = - 'data: {"choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":null}]}\n\n'; - const opts = options( - (async (_url: Parameters[0]) => - new Response(body, { - headers: { "content-type": "text/event-stream", "x-request-id": "eof-request" }, - })) as typeof fetch, - ); - const binding = opts.bindings.providers!.get("openai")!; - const configured = { - ...opts, - configuration: { - ...opts.configuration, - policy: { maxOutputTokens: 16384, provider: "openai", model: "streaming", stream: true }, - }, - bindings: { - ...opts.bindings, - providers: new Map([ - ["openai", { ...binding, transport: { ...binding.transport, capture: capture.capture } }], - ]), - }, - }; - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - const session = await createSession(configured); - try { - const result = await session.input("Review").settled; - expect(result).toMatchObject({ - record: { outcome: { kind: "failed", error: { operation: { kind: "completion" } } } }, - }); - const { calls } = await Bun.file(`${capture.directory}/manifest.json`).json(); - expect(calls[0]).toMatchObject({ - providerRequestId: "eof-request", - outcome: "failed", - model: "streaming", - }); - expect(await Bun.file(`${capture.directory}/${calls[0].responseFile}`).text()).toBe(body); - expect(await Bun.file(`${capture.directory}/diagnostics.log`).text()).toContain( - "provider.stream.failed", - ); - } finally { - await session.close(); - } - }); -}); - -test("peer review separates growing coordinator history from independent Anthropic extraction requests", async () => { - const { bindProviders, anthropicMessages } = await import("../providers/index.ts"); - const { CompletionPortRequestSchema } = await import("./index.ts"); - const capture = await createProviderCapture(".session-artifacts/peer-review"); - const independentSizes: number[] = []; - const coordinatorCounts: number[] = []; - const extractor = bindProviders( - new Map([ - [ - "anthropic", - { - profile: anthropicMessages, - transport: { - baseUrl: "https://anthropic-scripted.invalid/v1", - capture: capture.capture, - headers: { "x-api-key": "CAPTURE_SECRET_SENTINEL" }, - fetch: (async (_url: Parameters[0], init?: RequestInit) => { - independentSizes.push(new TextEncoder().encode(String(init?.body)).byteLength); - return Response.json({ - id: "extract-response", - type: "message", - role: "assistant", - content: [{ type: "text", text: '{"claims":["claim"]}' }], - stop_reason: "end_turn", - usage: { input_tokens: 10, output_tokens: 5 }, - }); - }) as typeof fetch, - }, - }, - ], - ]), - ); - let calls = 0; - const opts = options((async (_url: Parameters[0], init?: RequestInit) => { - coordinatorCounts.push(JSON.parse(String(init?.body)).messages.length); - return Response.json( - ++calls % 2 - ? { - choices: [ - { - finish_reason: "tool_calls", - message: { - content: "", - tool_calls: [ - { - id: "extract", - type: "function", - function: { name: "extract", arguments: '{"text":"same paper"}' }, - }, - ], - }, - }, - ], - } - : answer, - ); - }) as typeof fetch); - const binding = opts.bindings.providers!.get("openai")!; - const configured = { - ...opts, - bindings: { - ...opts.bindings, - providers: new Map([ - ["openai", { ...binding, transport: { ...binding.transport, capture: capture.capture } }], - ]), - tools: new Map([ - [ - "extract", - defineTool({ - input: z.object({ text: z.string() }), - run: async ({ text }, signal, context) => { - const result = await extractor.complete( - CompletionPortRequestSchema.parse({ - maxOutputTokens: 16384, - provider: "anthropic", - model: "scripted-extractor", - messages: [{ role: "user", content: text }], - }), - signal, - undefined, - undefined, - { ...context, childId: context?.toolCallId }, - ); - return result.completion; - }, - }), - ], - ]), - }, - }; - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - const session = await createSession(configured); - try { - expect(await session.input("Review").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - const restored = await restoreSession( - configured, - session.snapshot.durable.conversation.sessionId, - ); - expect(independentSizes).toHaveLength(1); - await restored.close(); - await session.input("Repeat explicitly").settled; - expect(independentSizes).toHaveLength(2); - expect(independentSizes[0]).toBe(independentSizes[1]); - expect(coordinatorCounts[2]!).toBeGreaterThan(coordinatorCounts[0]!); - const manifest = await Bun.file(`${capture.directory}/manifest.json`).json(); - expect( - manifest.calls - .filter((call: any) => call.provider === "anthropic") - .every((call: any) => call.toolCallId && call.turnId && call.sessionId), - ).toBe(true); - expect(JSON.stringify(manifest)).not.toContain("CAPTURE_SECRET_SENTINEL"); - await Bun.write( - `${capture.directory}/history-comparison.json`, - JSON.stringify( - { - coordinatorMessageCounts: coordinatorCounts, - independentAnthropicRequestBytes: independentSizes, - }, - null, - 2, - ), - ); - await Bun.write( - `${capture.directory}/history-comparison.md`, - [ - "# Peer-review request comparison", - "", - "Two explicit review invocations each run extraction once. Restore runs neither coordinator nor extractor.", - "", - "| Coordinator call | Projected messages |", - "| --- | --- |", - ...coordinatorCounts.map((count, index) => `| ${index + 1} | ${count} |`), - "", - "| Independent Anthropic extraction | HTTP request bytes |", - "| --- | --- |", - ...independentSizes.map((bytes, index) => `| ${index + 1} | ${bytes} |`), - "", - "Coordinator history grows; independent extraction request sizes stay equal. Full bodies and correlation are linked in [the traffic report](README.md). No storage representation optimization is applied.", - "", - ].join("\n"), - ); - const logs = (await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(logs.filter((log) => log.level === "warning" || log.level === "error")).toEqual([]); - } finally { - await session.close(); - } - }); -}); - -test("public completion binding example exposes exact requests, arguments and responses", async () => { - const { completionBindingExample } = await import("./examples/completion-binding.ts"); - const evidence = await completionBindingExample(); - expect(evidence.requests).toHaveLength(2); - expect(evidence.requests[1]!.messages).toMatchObject([ - { role: "user", content: "Review the document" }, - { - role: "assistant", - tool_calls: [ - { id: "call-1", function: { name: "extract", arguments: '{"document":"A claim"}' } }, - ], - }, - { role: "tool", tool_call_id: "call-1", content: '{"claim":"A claim"}' }, - ]); - expect(evidence.responses[1]).toMatchObject({ completion: { kind: "answer" } }); -}); - -test("retained provider rejection preserves code and status while redacting credential echoes", async () => { - const capture = await createProviderCapture(".session-artifacts/consumer-rejection"); - const secret = "configured-provider-secret"; - const opts = options( - (async (_url: Parameters[0]) => - new Response( - JSON.stringify({ error: { code: "quota_exhausted", message: `No quota for ${secret}` } }), - { status: 429, headers: { "x-request-id": "quota-request" } }, - )) as typeof fetch, - ); - const binding = opts.bindings.providers!.get("openai")!; - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - const session = await createSession({ - ...opts, - bindings: { - ...opts.bindings, - providers: new Map([ - [ - "openai", - { - ...binding, - transport: { - ...binding.transport, - headers: { Authorization: `Bearer ${secret}` }, - capture: capture.capture, - }, - }, - ], - ]), - }, - }); - try { - const result = await session.input("Review").settled; - expect(result).toMatchObject({ - record: { - outcome: { - kind: "failed", - error: { classification: "execution", operation: { kind: "completion" } }, - }, - }, - }); - const terminal = JSON.stringify(result); - expect(terminal).toContain('"httpStatus":429'); - expect(terminal).toContain("quota-request"); - expect(terminal).toContain("quota_exhausted"); - expect(terminal).not.toContain(secret); - const { calls } = await Bun.file(`${capture.directory}/manifest.json`).json(); - const response = await Bun.file(`${capture.directory}/${calls[0].responseFile}`).text(); - expect(response).toContain("quota_exhausted"); - expect(response).toContain("[REDACTED]"); - expect(response).not.toContain(secret); - const logs = await Bun.file(`${capture.directory}/diagnostics.log`).text(); - expect(logs).not.toContain(secret); - expect( - logs - .split("\n") - .filter((line) => line.includes("WARNING")) - .join("\n"), - ).toContain("quota_exhausted"); - } finally { - await session.close(); - } - }); -}); - -test("in-progress stream evidence reaches disk before the operation finishes", async () => { - const capture = await createProviderCapture(".session-artifacts/consumer-partial"); - const recorded = deferred(); - const finished = deferred(); - const partial = - 'data: {"choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":null}]}\n\n'; - const opts = options( - (async (_url: Parameters[0]) => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(partial)); - }, - }), - { headers: { "content-type": "text/event-stream" } }, - )) as typeof fetch, - ); - const binding = opts.bindings.providers!.get("openai")!; - const session = await createSession({ - ...opts, - configuration: { - ...opts.configuration, - policy: { maxOutputTokens: 16384, provider: "openai", model: "streaming", stream: true }, - }, - bindings: { - ...opts.bindings, - providers: new Map([ - [ - "openai", - { - ...binding, - transport: { - ...binding.transport, - capture: async (event) => { - await capture.capture(event); - if (event.kind === "http_response" && event.body === partial) recorded.resolve(); - if (event.kind === "completion") finished.resolve(); - }, - }, - }, - ], - ]), - }, - }); - try { - const turn = session.input("Review"); - await recorded.promise; - const { calls } = await Bun.file(`${capture.directory}/manifest.json`).json(); - expect(calls[0]).toMatchObject({ outcome: "in_progress", transportPhase: "stream" }); - expect(await Bun.file(`${capture.directory}/${calls[0].responseFile}`).text()).toBe(partial); - await session.fire({ type: "abort" }); - expect(await turn.settled).toMatchObject({ record: { outcome: { kind: "aborted" } } }); - await finished.promise; - } finally { - await session.close(); - } -}); - -test("batch cancellation carries the initiating tool failure to siblings and diagnostics", async () => { - const capture = await createProviderCapture(".session-artifacts/consumer-cancellation"); - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - let siblingSignal: AbortSignal | undefined; - const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { - agent: "a", - agents: new Map([["a", { model: "scripted", tools: ["extract"] }]]), - steps: 3, - }, - bindings: { - complete: () => ({ - kind: "tools", - text: "", - calls: [ - { id: "first", name: "extract", args: { fail: true } }, - { id: "sibling", name: "extract", args: { fail: false } }, - ], - }), - tools: new Map([ - [ - "extract", - defineTool({ - input: z.object({ fail: z.boolean() }), - run: ({ fail }, signal) => { - if (fail) throw new Error("extract failed"); - siblingSignal = signal; - return new Promise(() => {}); - }, - }), - ], - ]), - }, - }); - try { - expect(await session.input("Go").settled).toMatchObject({ - record: { - outcome: { - kind: "failed", - error: { - operation: { callId: "first", toolName: "extract" }, - message: "extract failed", - }, - }, - }, - }); - expect(siblingSignal?.aborted).toBe(true); - expect(siblingSignal?.reason).toMatchObject({ - classification: "cancelled", - message: "extract failed", - operation: { callId: "first" }, - }); - const records = (await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect( - records.some( - (record) => - record.event === "child.cancellation_requested" && - record.childId.endsWith("/sibling") && - record.reason?.operation?.callId === "first", - ), - ).toBe(true); - } finally { - await session.close(); - } - }); -}); - -test("completion usage is receipt-gated public data and survives restore without HTTP", async () => { - const capture = await createProviderCapture(".session-artifacts/completion-usage"); - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - let calls = 0; - const reached = deferred(); - const release = deferred(); - const opts = options((async (_url: Parameters[0]) => { - calls++; - return Response.json( - { - ...answer, - usage: { - prompt_tokens: 120, - completion_tokens: 30, - total_tokens: 150, - prompt_tokens_details: { cached_tokens: 100 }, - note: "echo usage-credential-secret", - }, - }, - { headers: { "x-request-id": "usage-response-1" } }, - ); - }) as typeof fetch); - const persistence = opts.persistence; - const binding = opts.bindings.providers!.get("openai")!; - const configured: SessionOptions = { - ...opts, - persistence: { - ...persistence, - async append(request, signal) { - if ( - request.records - .map((record) => JSON.parse(record)) - .some( - ({ body }) => - body.kind === "event" && - body.event.type === "child" && - body.event.event.type === "model_settled", - ) - ) { - reached.resolve(); - await release.promise; - } - return persistence.append(request, signal); - }, - }, - bindings: { - ...opts.bindings, - providers: new Map([ - [ - "openai", - { - ...binding, - transport: { - ...binding.transport, - capture: capture.capture, - headers: { Authorization: "Bearer usage-credential-secret" }, - }, - }, - ], - ]), - }, - }; - const session = await createSession(configured); - const sessionId = session.snapshot.durable.conversation.sessionId; - try { - const handle = session.input("Extract the field"); - await reached.promise; - expect(session.lastCompletionUsage).toBeUndefined(); - release.resolve(); - expect(await handle.settled).toMatchObject({ record: { outcome: { kind: "completed" } } }); - expect(session.lastCompletionUsage).toMatchObject({ - turnId: `${sessionId}/turn/1`, - usage: { - inputTokens: 120, - outputTokens: 30, - totalTokens: 150, - native: { prompt_tokens_details: { cached_tokens: 100 } }, - source: { - provider: "openai", - model: "fast", - wireModel: "wire-fast", - profile: "openai-chat@1", - providerRequestId: "usage-response-1", - }, - }, - }); - const usage = session.lastCompletionUsage!; - expect(Object.isFrozen(usage.usage.native)).toBe(true); - expect(JSON.stringify(usage)).not.toContain("usage-credential-secret"); - expect(usage.usage.source?.httpRequestId).toBeString(); - await Bun.write( - `${capture.directory}/journal.json`, - JSON.stringify(session.snapshot.durable.records, null, 2), - ); - await session.close(); - const restored = await restoreSession(configured, sessionId); - try { - expect(restored.lastCompletionUsage).toEqual(usage); - expect(calls).toBe(1); - } finally { - await restored.close(); - } - } finally { - release.resolve(); - await session.close(); - } - }); - expect(await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()).not.toContain( - "usage-credential-secret", - ); - const lines = (await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const committed = lines.filter((line) => line.event === "completion.usage.committed"); - expect(committed).toHaveLength(1); - expect(committed[0]).toMatchObject({ - level: "info", - usage: { inputTokens: 120 }, - sessionId: expect.any(String), - turnId: expect.any(String), - childId: expect.any(String), - appendId: expect.any(String), - }); - expect(lines.filter((line) => ["warning", "error", "fatal"].includes(line.level))).toEqual([]); -}); - -test("invalid accounting is reported and retained without invalidating a usable completion", async () => { - const capture = await createProviderCapture(".session-artifacts/completion-usage-failure"); - await withFixtureDiagnostics(capture.directory, { runId: capture.runId }, async () => { - const opts = options((async (_url: Parameters[0]) => - Response.json( - { - ...answer, - usage: { prompt_tokens: -1, completion_tokens: 30, total_tokens: 29 }, - }, - { headers: { "x-request-id": "invalid-usage-response" } }, - )) as typeof fetch); - const binding = opts.bindings.providers!.get("openai")!; - const session = await createSession({ - ...opts, - bindings: { - ...opts.bindings, - providers: new Map([ - [ - "openai", - { - ...binding, - transport: { - ...binding.transport, - capture: capture.capture, - }, - }, - ], - ]), - }, - }); - try { - const result = await session.input("Extract").settled; - expect(result).toMatchObject({ record: { outcome: { kind: "completed" } } }); - const usage = session.lastCompletionUsage?.usage; - expect(usage?.status).toBe("invalid"); - if (usage?.status !== "invalid") throw new Error("Missing invalid accounting evidence"); - expect(usage.error).toContain("usage.prompt_tokens"); - expect(usage.native).toEqual({ prompt_tokens: -1, completion_tokens: 30, total_tokens: 29 }); - expect(session.lastCompletionUsage?.usage).not.toHaveProperty("inputTokens"); - await Bun.write( - `${capture.directory}/public-usage.json`, - JSON.stringify(session.lastCompletionUsage), - ); - } finally { - await session.close(); - } - }); - const lines = (await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(lines.filter((line) => line.event === "completion.usage.committed")).toHaveLength(1); - expect(lines.find((line) => line.event === "completion.usage.committed")).toMatchObject({ - usage: { status: "invalid", error: expect.stringContaining("usage.prompt_tokens") }, - }); - expect(lines.find((line) => line.event === "provider.usage.invalid")).toMatchObject({ - level: "warning", - providerRequestId: "invalid-usage-response", - error: expect.stringContaining("usage.prompt_tokens"), - }); -}); diff --git a/packages/core-agent/session/continuation-blobs.test.ts b/packages/core-agent/session/continuation-blobs.test.ts deleted file mode 100644 index 9f9092f3..00000000 --- a/packages/core-agent/session/continuation-blobs.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { PreparedModelSchema } from "../agent/agent.ts"; -import { SessionIdSchema } from "../agent/types.ts"; -import { - ContinuationSchema, - googleGenerateV2, - openaiResponsesV2, - type CompletionProfile, -} from "../providers/index.ts"; -import { continuationPayload } from "../providers/shared.ts"; -import { resolveRequestBlobs, storeContinuation } from "./blobs.ts"; -import { decodeRecord, journalJSONL } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deferred, deterministicIds, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -const signal = () => new AbortController().signal; - -const owner = { turnId: "turn", generation: 1 }; - -test("continuation representation boundary, operation-only resolution, and exclusive schema", async () => { - const base = createMemoryPersistence(); - let reads = 0; - let puts = 0; - const port = { - ...base, - getBlob: async (...args: Parameters) => { - reads++; - return base.getBlob(...args); - }, - putBlob: async (...args: Parameters) => { - puts++; - return base.putBlob(...args); - }, - }; - const sessionId = SessionIdSchema.parse(deterministicIds()()); - for (const length of [10, 65536, 65537, 70 * 1024]) { - const payload = "x".repeat(length - 2); // Quotes count toward the serialized JSON cap. - const entry = await storeContinuation( - port, - sessionId, - { - provider: googleGenerateV2.id, - owner: ContinuationSchema.parse({ provider: googleGenerateV2.id, owner, payload: null }) - .owner, - payload, - }, - signal(), - ); - expect(entry.payload !== undefined).toBe(length <= 65536); - expect(entry.payloadBlob !== undefined).toBe(length > 65536); - const request = PreparedModelSchema.parse({ - maxOutputTokens: 16384, - model: "test", - provider: googleGenerateV2.id, - messages: [{ role: "assistant", content: "answer", owner }], - continuations: [entry], - }); - const before = reads; - await resolveRequestBlobs(port, sessionId, request, signal()); - expect(reads).toBe(before); - const blobs = await resolveRequestBlobs(port, sessionId, request, signal(), true); - expect(continuationPayload(entry, blobs)).toBe(payload); - expect(reads - before).toBe(length > 65536 ? 1 : 0); - if (entry.payloadBlob) { - expect(() => ContinuationSchema.parse({ ...entry, payload })).toThrow(); - expect(() => continuationPayload(entry, () => new Uint8Array())).toThrow("do not match"); - } - } - expect(puts).toBe(2); - expect(() => ContinuationSchema.parse({ provider: googleGenerateV2.id, owner })).toThrow(); -}); - -function setup(profile: CompletionProfile) { - const base = createMemoryPersistence(); - const bodies: any[] = []; - const lifecycle: string[] = []; - const secret = "X".repeat(70 * 1024); - let count = 0; - const opts: SessionOptions = { - persistence: { - ...base, - putBlob: async (...args) => { - lifecycle.push("put"); - return base.putBlob(...args); - }, - getBlob: async (...args) => { - lifecycle.push("get"); - return base.getBlob(...args); - }, - append: async (request, abort) => { - for (const raw of request.records) { - const event = JSON.parse(raw).body.event?.event; - if (event?.continuation) lifecycle.push("settled"); - } - return base.append(request, abort); - }, - }, - configuration: { - agent: "a", - agents: new Map([["a", { model: "fixture-model", tools: ["echo"], successors: [] }]]), - steps: 4, - policy: { - thinkingBudgetTokens: profile === googleGenerateV2 ? 1024 : null, - maxOutputTokens: 16384, - provider: profile.id, - thinking: profile === googleGenerateV2 ? "budget" : "high", - }, - }, - bindings: { - id: deterministicIds(), - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({}), - run: () => { - lifecycle.push("tool"); - return "ok"; - }, - }), - ], - ]), - providers: new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async (_url, init) => { - lifecycle.push("fetch"); - bodies.push(JSON.parse(String(init?.body))); - const round = count++; - return Response.json( - profile === googleGenerateV2 - ? { - candidates: [ - { - finishReason: "STOP", - content: { - role: "model", - parts: - round === 0 - ? [ - { - functionCall: { id: "c", name: "echo", args: {} }, - thoughtSignature: secret, - }, - ] - : [{ text: "done" }], - }, - }, - ], - } - : { - status: "completed", - output: - round === 0 - ? [ - { - type: "reasoning", - id: "rs_0", - summary: [], - encrypted_content: secret, - }, - { - type: "function_call", - call_id: "c", - name: "echo", - arguments: "{}", - }, - ] - : [ - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "done" }], - }, - ], - }, - ); - }) as typeof fetch, - }, - }, - ], - ]), - }, - }; - return { opts, bodies, lifecycle, secret, base }; -} - -for (const profile of [googleGenerateV2, openaiResponsesV2]) - test(`${profile.id}: large continuations journal refs, restore lazily, fork copies and compact drops`, async () => { - const { opts, bodies, lifecycle, secret, base } = setup(profile); - const session = await createSession(opts); - const result = await session.input("Go").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(JSON.stringify(bodies[1])).toContain(secret); - const durable = session.snapshot.durable; - const entry = durable.continuations![0]!; - expect(entry.payload).toBeUndefined(); - expect(entry.payloadBlob).toBeDefined(); - expect(journalJSONL(durable)).not.toContain(secret); - expect(lifecycle).toEqual(["fetch", "put", "settled", "tool", "get", "fetch"]); - const settled = durable.records.find( - (record) => - record.body.kind === "event" && - record.body.event.type === "child" && - record.body.event.event.type === "model_settled" && - record.body.event.event.continuation, - )!; - expect(settled.version).toBe(2); - expect(() => decodeRecord(JSON.stringify({ ...settled, version: 4 }))).toThrow(); - const before = lifecycle.length; - const restored = await restoreSession(opts, durable.conversation.sessionId); - expect(lifecycle.length).toBe(before); - expect(restored.snapshot.durable.continuations).toEqual(durable.continuations); - const fork = await session.fork(); - expect( - await base.getBlob( - fork.snapshot.durable.conversation.sessionId, - entry.payloadBlob!.id, - signal(), - ), - ).not.toEqual({ kind: "not_found" }); - const created = fork.snapshot.durable.records[0]!; - expect(() => decodeRecord(JSON.stringify({ ...created, version: 4 }))).toThrow(); - await fork.input("Next").settled; - expect(JSON.stringify(bodies.at(-1))).toContain(secret); - const compact = await session.compact([]); - expect(compact.snapshot.durable.continuations ?? []).toEqual([]); - expect( - await base.getBlob( - compact.snapshot.durable.conversation.sessionId, - entry.payloadBlob!.id, - signal(), - ), - ).toEqual({ kind: "not_found" }); - await Promise.all([session, restored, fork, compact].map((item) => item.close())); - }); - -test("blob write failure or cancellation cannot journal a continuation or release tools", async () => { - for (const cancel of [false, true]) { - const { opts, lifecycle } = setup(openaiResponsesV2); - const gate = deferred(); - let writeSignal: AbortSignal | undefined; - const session = await createSession({ - ...opts, - persistence: { - ...opts.persistence, - putBlob: async (...args) => { - writeSignal = args[3]; - if (cancel) await gate.promise; - throw new Error("write failed"); - }, - }, - }); - const pending = session.input("Go").settled; - await until(() => !!writeSignal); - if (cancel) { - await session.dispatch({ type: "abort" }); - await until(() => writeSignal!.aborted); - expect(writeSignal!.aborted).toBe(true); - gate.resolve(); - } - const result = await pending; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe( - cancel ? "aborted" : "failed", - ); - expect(session.snapshot.durable.continuations ?? []).toEqual([]); - expect(lifecycle).not.toContain("tool"); - await session.close(); - } -}); diff --git a/packages/core-agent/session/domain-types.test.ts b/packages/core-agent/session/domain-types.test.ts deleted file mode 100644 index e6d6c692..00000000 --- a/packages/core-agent/session/domain-types.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import type { PolicyPatch } from "../policy/policy.ts"; -import type { EnvEvent } from "./events.ts"; -import type { AppendResult, Revision } from "./persistence.ts"; -import type { SessionState } from "./session-fsm.ts"; -import type { StorageRef } from "./session-operation.ts"; - -// Compile-time assertions; bunx tsc --noEmit is required in addition to the test runner. -function assertions(state: SessionState, result: AppendResult) { - // @ts-expect-error A plain number is not a journal revision. - const revision: Revision = 1; - // @ts-expect-error Storage operations cannot masquerade as completions. - const storage: StorageRef = { kind: "completion", id: "x" }; - // @ts-expect-error Committing states require the staged batch and commands. - const committing: SessionState = { status: "committing", durable: state.durable, queue: [] }; - switch (result.kind) { - case "committed": - case "conflict": - case "rejected": - break; - default: { - // @ts-expect-error Indeterminate outcomes must be explicitly handled. - const exhausted: never = result; - void exhausted; - } - } - void revision; - void storage; - void committing; -} -test("domain assertions are checked by TypeScript", () => { - expect(typeof assertions).toBe("function"); -}); - -function policyAssertions() { - // @ts-expect-error Executable functions cannot enter a policy patch. - const patch: PolicyPatch = { project: () => [] }; - // @ts-expect-error Private child events cannot be public environment events. - const child: EnvEvent = { type: "child", event: {} }; - void patch; - void child; -} -void policyAssertions; diff --git a/packages/core-agent/session/events.ts b/packages/core-agent/session/events.ts deleted file mode 100644 index 89b63623..00000000 --- a/packages/core-agent/session/events.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from "zod"; - -import { BlobRefSchema } from "../agent/content.ts"; -import { parseSessionContext } from "../agent/prompt.ts"; -import { MessagesSchema } from "../agent/types.ts"; -import { PolicyPatchSchema } from "../policy/policy.ts"; - -/** - * Schema of the public events a caller submits with `SessionRuntime.dispatch`: - * - `user`: user input (text, stored attachment refs, or both). It starts a turn, joins the - * active turn by barge-in, is queued, or is refused, as the mid-turn input policy decides. - * - `abort`: cancels the active turn, which ends `aborted`. Already queued inputs are kept. - * - `system`: replaces the standing session instructions (not a system notice). - * - `policy`: patches the configuration for the next turn. - * - `fork` / `compact`: create a child session; `context` is the compacted child's replacement - * context and must hold complete tool exchanges. - * - `close`: closes the session; nothing is journaled. - */ -export const EnvEventSchema = z.discriminatedUnion("type", [ - z - .strictObject({ - type: z.literal("user"), - text: z.string().default(""), - attachments: z - .array(BlobRefSchema) - .readonly() - .transform((refs) => (refs.length ? refs : undefined)) - .optional(), - }) - .refine( - (event) => event.text.length > 0 || Boolean(event.attachments?.length), - "User input requires text or attachments", - ), - z.strictObject({ type: z.literal("abort") }), - z.strictObject({ type: z.literal("system"), inputs: z.array(z.string()).readonly() }), - z.strictObject({ type: z.literal("policy"), patch: PolicyPatchSchema }), - z.strictObject({ type: z.literal("fork") }), - z.strictObject({ - type: z.literal("compact"), - context: MessagesSchema.transform(parseSessionContext), - }), - z.strictObject({ type: z.literal("close") }), -]); -/** A public session event before validation (input shape of {@link EnvEventSchema}). */ -export type EnvEvent = z.input; diff --git a/packages/core-agent/session/examples/completion-binding.ts b/packages/core-agent/session/examples/completion-binding.ts deleted file mode 100644 index 39f25921..00000000 --- a/packages/core-agent/session/examples/completion-binding.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from "zod"; - -import { - createSession, - defineTool, - type CompletionPortRequest, - type CompletionPortResponse, -} from "../index.ts"; -import { createMemoryPersistence } from "../testing/memory-persistence.ts"; - -/** Executable scripted binding: these are port invocations, not HTTP requests. */ -export async function completionBindingExample() { - const requests: CompletionPortRequest[] = []; - const responses: CompletionPortResponse[] = []; - const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { - agent: "reviewer", - agents: new Map([["reviewer", { model: "scripted", tools: ["extract"] }]]), - steps: 3, - }, - bindings: { - tools: new Map([ - [ - "extract", - defineTool({ - input: z.object({ document: z.string() }), - run: ({ document }) => ({ claim: document }), - }), - ], - ]), - complete(request) { - requests.push(request); - const response: CompletionPortResponse = - requests.length === 1 - ? { - completion: { - kind: "tools", - text: "Extracting", - calls: [{ id: "call-1", name: "extract", args: { document: "A claim" } }], - }, - } - : { completion: { kind: "answer", text: "Reviewed the extracted claim" } }; - responses.push(response); - return response; - }, - }, - }); - try { - const result = await session.input("Review the document").settled; - return { evidence: "scripted_completion_port", requests, responses, result }; - } finally { - await session.close(); - } -} - -if (import.meta.main) console.log(JSON.stringify(await completionBindingExample(), null, 2)); diff --git a/packages/core-agent/session/fixture-reporting.test.ts b/packages/core-agent/session/fixture-reporting.test.ts deleted file mode 100644 index 99a1d0e1..00000000 --- a/packages/core-agent/session/fixture-reporting.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { runFixture } from "./fixture-runner.ts"; -import { plainConversationV1 } from "./fixtures/conversation.ts"; -import { interruptedRecoveryV1, rejectedAppendV1 } from "./fixtures/persistence.ts"; -import { partialCancellationLateResultV1, permissionRejectBatchV2 } from "./fixtures/tools.ts"; -import { approveToolUsage, denyToolUsage } from "./fixtures/usage.ts"; -import { createSession } from "./session-runtime.ts"; - -const directory = ".session-artifacts/reporting"; - -test("conversation reports introduce actors, quote messages, and explain identical restored views", async () => { - const result = await runFixture(plainConversationV1, { - artifactDirectory: `${directory}/conversation`, - }); - expect(result.report).toContain(plainConversationV1.purpose); - expect(result.report).toContain("Executable example:"); - expect(result.report).toContain("captured before cleanup"); - expect(result.transcript).toContain("Primary assistant (agent ID: a)"); - expect(result.transcript).toContain("### User\n\n> Hi"); - expect(result.transcript).toContain("### Assistant\n\n> Hello"); - expect(result.transcript).toContain("Same session ID and durable state"); - expect(result.transcript.match(/> Hello/g)).toHaveLength(1); - expect(result.transcript).not.toContain('"role":'); - expect(result.report).not.toContain('"messages":'); -}); - -test("refusal report explains the refused call, the approved call, and the completed turn", async () => { - const result = await runFixture(permissionRejectBatchV2, { - artifactDirectory: `${directory}/refusal`, - }); - expect(result.transcript).toContain("**Tool call: echo** (call ID: one)"); - expect(result.transcript).toContain('```json\n{\n "text": "one"\n}\n```'); - expect(result.transcript).toContain("Call one: **allow_once**"); - expect(result.transcript).toContain("Call two: **reject_once**"); - expect(result.transcript).toContain("Tool result — call two"); - expect(result.transcript).toContain('"refused": true'); - expect(result.transcript).toContain( - '"reason": "The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed."', - ); - expect(result.transcript).toContain("**Outcome:** completed."); - expect(result.report).toContain("Only the approved call executes"); - expect(result.transcript).not.toContain("durable state changed during recovery"); -}); - -test("recovery report distinguishes interrupted work, committed partial results, and a recovered terminal", async () => { - const result = await runFixture(interruptedRecoveryV1, { - artifactDirectory: `${directory}/recovery`, - }); - expect(result.transcript).toContain("Unfinished turn — executing_tools"); - expect(result.transcript).toContain("Committed partial tool outcome"); - expect(result.transcript).toContain("Tool result — call fast"); - expect(result.transcript).toContain("Interrupted session; external effects were not replayed"); - expect(result.report).toContain("Provider request count is unchanged by restore"); -}); - -test("a failed named assertion fails the runner after preserving its report, evidence, and cleanup logs", async () => { - const location = `${directory}/deliberate-failure`; - const scenario = { - ...plainConversationV1, - name: "deliberate-failure", - async run(f: Parameters[0]) { - f.track("root", await createSession(f.options)); - f.action("Demonstrate how an incorrect expectation is reported."); - f.check( - "A new session has no completed turns", - f.sessions.get("root")!.snapshot.durable.conversation.log.length, - 99, - ); - }, - }; - await expect( - runFixture(scenario, { artifactDirectory: location, compareBaseline: false }), - ).rejects.toThrow("A new session has no completed turns"); - const report = await Bun.file(`${location}/README.md`).text(); - expect(report).toContain("**FAIL**"); - expect(report).toContain("Expected: 99; observed: 0"); - const error = await Bun.file(`${location}/evidence/error.json`).json(); - expect(error.message).toContain("A new session has no completed turns"); - expect(error.stack).toContain("fixture-reporting.test.ts"); - expect(await Bun.file(`${location}/diagnostics.log`).text()).toContain("host.closed"); - expect(await Bun.file(`${location}/evidence/journal-root.jsonl`).exists()).toBe(true); - // Reusing an artifact directory must not leave a stale failure behind. - await runFixture(plainConversationV1, { artifactDirectory: location }); - expect(await Bun.file(`${location}/evidence/error.json`).exists()).toBe(false); -}); - -test("a baseline mismatch names the evidence field and preserves expected and observed values", async () => { - const location = `${directory}/deliberate-baseline-mismatch`; - await expect( - runFixture( - { - ...plainConversationV1, - purpose: "Deliberately change the first answer to verify behavioral mismatch reporting.", - dependencies: { - completions: [ - { kind: "answer", text: "Changed answer" }, - { kind: "answer", text: "Again" }, - ], - }, - }, - { artifactDirectory: location }, - ), - ).rejects.toThrow("Exact requests match the approved behavioral evidence"); - const error = await Bun.file(`${location}/evidence/error.json`).json(); - expect(error.message).toContain("Changed answer"); - expect(error.message).toContain("Hello"); - expect(error.message).toContain("$.1.messages"); - expect(await Bun.file(`${location}/README.md`).text()).toContain("**FAIL**"); -}); - -test("cancelled work and a rejected admission are explained as expected outcomes", async () => { - const cancelled = await runFixture(partialCancellationLateResultV1, { - artifactDirectory: `${directory}/cancelled`, - }); - expect(cancelled.transcript).toContain("aborted — turn cancelled"); - expect(cancelled.transcript).not.toContain("> late"); - const rejected = await runFixture(rejectedAppendV1, { - artifactDirectory: `${directory}/rejected`, - }); - expect(rejected.report).toContain("**PASS**"); - expect(rejected.report).toContain("Input admission is failed"); - expect(rejected.transcript).toContain("No terminal turns have been committed"); -}); - -test("warning-only logs expose user refusal and its consequence; approved work emits no warnings", async () => { - for (const scenario of [denyToolUsage, approveToolUsage]) { - const location = `${directory}/${scenario.name}-warning-scan`; - await runFixture(scenario, { artifactDirectory: location }); - const records = (await Bun.file(`${location}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const signals = records.filter( - (record) => record.level === "warning" || record.level === "error", - ); - const decision = records.find((record) => record.event === "permission.decided"); - expect(decision.level).toBe("info"); - if (scenario === denyToolUsage) { - expect(signals.map((record) => record.event)).toEqual(["permission.refused"]); - expect(signals[0]).toMatchObject({ - reasonCode: "permission_refused", - toolName: "read_note", - rawInput: { name: "design" }, - outcome: "blocked", - }); - expect(records.find((record) => record.event === "turn.settled")).toMatchObject({ - level: "info", - operation: "agent_turn", - agentId: "reviewer", - outcome: "completed", - }); - const text = await Bun.file(`${location}/diagnostics.log`).text(); - const warningLines = text.split("\n").filter((line) => line.includes(" WARNING ")); - expect(warningLines[0]).toContain("permission.refused"); - expect(warningLines[0]).toContain("User refused permission"); - expect(warningLines).toHaveLength(1); - } else { - expect(signals).toEqual([]); - expect(records.find((record) => record.event === "turn.settled").level).toBe("info"); - } - } -}); diff --git a/packages/core-agent/session/fixture-runner.ts b/packages/core-agent/session/fixture-runner.ts deleted file mode 100644 index 4b5a14b3..00000000 --- a/packages/core-agent/session/fixture-runner.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import { rm } from "node:fs/promises"; -import { relative, resolve } from "node:path"; -import { isDeepStrictEqual } from "node:util"; - -import { configure, getConfig } from "@logtape/logtape"; -import { format, resolveConfig } from "prettier"; -import { z } from "zod"; - -import { withFixtureDiagnostics } from "../logging/fixture-capture.ts"; -import { diagnostic, diagnosticError } from "../logging/index.ts"; -import { scenarios } from "./fixtures/index.ts"; -import { fixtureEnvironment, type Scenario } from "./fixtures/support.ts"; -import { journalJSONL, journalMarkdown, type JournalState } from "./session-log.ts"; - -export { scenarios } from "./fixtures/index.ts"; - -const fixtureRoot = new URL("./fixtures/", import.meta.url); - -const defaultArtifacts = new URL("../../../.session-artifacts/latest/", import.meta.url).pathname; - -const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; - -const baselineSchema = z.array( - z - .object({ - name: z.string(), - requests: z.array(z.unknown()), - results: z.array(z.unknown()), - states: z.record(z.string(), z.unknown()), - }) - .passthrough(), -); - -const labels = { a: "Primary assistant", b: "Handoff specialist" }; - -async function logging() { - if (!getConfig()) - await configure({ - contextLocalStorage: new AsyncLocalStorage(), - sinks: {}, - loggers: [{ category: ["logtape", "meta"], lowestLevel: "warning", sinks: [] }], - }); -} - -/** JSON evidence deliberately has no undefined properties, exactly like the frozen baselines. */ -function evidence(value: unknown): unknown { - return JSON.parse(JSON.stringify(value)); -} - -function difference(actual: unknown, expected: unknown, path = "$"): unknown { - if (isDeepStrictEqual(actual, expected)) return null; - if ( - actual !== null && - expected !== null && - typeof actual === "object" && - typeof expected === "object" - ) { - const a = actual as Record; - const b = expected as Record; - for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) { - const mismatch = difference(a[key], b[key], `${path}.${key}`); - if (mismatch !== null) return mismatch; - } - } - return { path, expected, actual }; -} - -function display(value: unknown): string { - if (Array.isArray(value)) { - if (value.every((item) => typeof item === "string")) - return value.length ? value.join(", ") : "none"; - return `${value.length} structured entries (values in assertions.json)`; - } - if (value !== null && typeof value === "object") - return "structured value (details in assertions.json)"; - return value === null ? "no difference" : String(value); -} - -function transcript(states: Record) { - const seen = new Map(); - return Object.entries(states) - .map(([alias, state]) => { - const id = state.conversation.sessionId; - const previous = seen.get(id); - if (previous && isDeepStrictEqual(evidence(state), evidence(previous.state))) - return `# Restored view: ${alias}\n\nSame session ID and durable state as **${previous.alias}**. The conversation is shown once above. Restore assertions in [the scenario report](README.md) check whether provider work occurred.\n`; - seen.set(id, { alias, state }); - return `# View: ${alias}\n\n${previous ? `Another captured view of **${previous.alias}** (revision ${previous.state.revision} → ${state.revision}). Differences are shown below; recovery is identified only when a recovery record exists.\n\n` : ""}${journalMarkdown(state, { agentLabels: labels })}`; - }) - .join("\n---\n\n"); -} - -async function executeScenario( - scenario: Scenario, - directory: string, - runId: string, - compare: boolean, -) { - // Remove only this generated scenario directory; never leave an old error file after a passing run. - await rm(directory, { recursive: true, force: true }); - return withFixtureDiagnostics( - directory, - { runId, scenario: scenario.name, fixtureVersion: scenario.version }, - async () => { - const f = fixtureEnvironment(scenario.dependencies); - let failure: ReturnType | undefined; - let states: Record = {}; - try { - await scenario.run(f); - states = Object.fromEntries( - [...f.sessions].map(([alias, session]) => [alias, session.snapshot.durable]), - ); - if (compare && scenario.baseline !== "assertions") { - const suffix = scenario.version === 2 ? "-v2" : ""; - const baseline = baselineSchema - .parse(await Bun.file(new URL(`expected${suffix}.json`, fixtureRoot)).json()) - .find((item) => item.name === scenario.name); - if (!baseline) throw new Error(`Missing behavioral baseline for ${scenario.name}`); - for (const [name, actual] of Object.entries({ - requests: f.requests, - results: f.results, - states, - })) { - // States contain the journal records, so record ordering and bytes are included in this check. - f.check( - `Exact ${name} match the approved behavioral evidence (see evidence/${name === "results" ? "outputs" : name}.json)`, - difference(evidence(actual), baseline[name]), - null, - ); - } - const expectedJournals = Object.fromEntries( - Object.entries(baseline.states).map(([alias, state]) => { - const { records } = z.object({ records: z.array(z.unknown()) }).parse(state); - return [alias, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`]; - }), - ); - f.check( - "Encoded journal bytes match the approved records", - difference( - Object.fromEntries( - Object.entries(states).map(([alias, state]) => [alias, journalJSONL(state)]), - ), - expectedJournals, - ), - null, - ); - } - f.check( - "Captured evidence excludes the fixture API key", - json({ requests: f.requests, results: f.results, states }).includes("SECRET_SENTINEL"), - false, - ); - } catch (error) { - failure = diagnosticError(error); - diagnostic("fixture", "error", "scenario.failed", { error: failure }); - } finally { - // Capture the state under test before cleanup; logs deliberately include cleanup as well. - states = Object.fromEntries( - [...f.sessions].map(([alias, session]) => [alias, session.snapshot.durable]), - ); - for (const [alias, session] of f.sessions) { - try { - await session.close(); - } catch (error) { - const cleanupError = diagnosticError(error); - diagnostic("fixture", "error", "scenario.cleanup.failed", { - alias, - error: cleanupError, - }); - failure = failure - ? diagnosticError( - new AggregateError([failure, cleanupError], "Scenario and cleanup failed"), - ) - : cleanupError; - } - } - } - const output = { name: scenario.name, requests: f.requests, results: f.results, states }; - const aliases: Record = {}; - for (const [alias, state] of Object.entries(states)) { - aliases[state.conversation.sessionId] ??= []; - aliases[state.conversation.sessionId]!.push(alias); - await Bun.write(`${directory}/evidence/journal-${alias}.jsonl`, journalJSONL(state)); - } - await Bun.write(`${directory}/evidence/requests.json`, json(f.requests)); - await Bun.write(`${directory}/evidence/outputs.json`, json(f.results)); - await Bun.write(`${directory}/evidence/states.json`, json(states)); - await Bun.write(`${directory}/evidence/session-aliases.json`, json(aliases)); - await Bun.write( - `${directory}/evidence/assertions.json`, - json(f.observations.filter((o) => o.kind === "assertion")), - ); - if (failure) await Bun.write(`${directory}/evidence/error.json`, json(failure)); - await Bun.write( - `${directory}/evidence/README.md`, - `# Exact execution evidence\n\nThese files support assertions and detailed investigation. Start with [the scenario report](../README.md) for the story.\n\n- journal-*.jsonl: authoritative committed records, one captured view per session alias.\n- requests.json: actual requests sent to the simulated provider, in order.\n- outputs.json: actual admission receipts, permission requests and terminal results.\n- states.json: durable snapshots **before cleanup**, including embedded journal records.\n- session-aliases.json: joins readable aliases to session IDs in diagnostics.\n- assertions.json: executed checks with expected and observed values.\n- error.json: failure details, when this run failed.\n\nSnapshots describe the end of the scenario body. Diagnostics also include subsequent cleanup. Restore aliases can refer to the same session ID. No blob bytes are placed in the journal.\n`, - ); - const rendered = transcript(states); - await Bun.write(`${directory}/transcript.md`, rendered); - const source = scenario.source.split("#")[0]!; - const sourceLink = relative(resolve(directory), resolve(source)) - .split("/") - .map(encodeURIComponent) - .join("/"); - const report = [ - `# ${scenario.name}`, - "", - `**${failure ? "FAIL" : "PASS"}** · suite v${scenario.version} · ${scenario.group}`, - "", - scenario.purpose, - "", - "PASS means the scenario satisfied its assertions. A deliberately refused, cancelled, or failed user turn can be the expected result.", - "", - `Executable example: [${scenario.source}](${sourceLink}).`, - "", - "## Test environment", - "", - scenario.environment ?? - "The real session runtime runs against process-local memory persistence and scripted provider responses. IDs are deterministic. No live provider or disk crash durability is tested. Primary assistant = agent `a`; handoff specialist = agent `b`. The echo tool returns its input; scripted deferred/error inputs exercise cancellation and failure.", - "", - `Storage fault: ${scenario.dependencies.fault ?? "none"}. Provider: ${scenario.dependencies.policy?.provider ?? "injected completion port"}.`, - "", - "## Execution and checks", - "", - ...f.observations.flatMap((o, index) => [ - `${index + 1}. ${o.kind === "action" ? o.description : `**${o.passed ? "PASS" : "FAIL"}** — ${o.description}. Expected: ${display(o.expected)}; observed: ${display(o.actual)}. [Values](evidence/assertions.json).`}`, - "", - ]), - ...(failure - ? [ - "## Failure", - "", - "The scenario did not satisfy its checks. Full cause and stack: [error.json](evidence/error.json).", - "", - "```json", - json(failure).trimEnd(), - "```", - "", - ] - : []), - "## Read next", - "", - "- [Conversation transcript](transcript.md)", - "- [Runtime diagnostics](diagnostics.log) / [structured diagnostics](diagnostics.jsonl)", - "- [Exact evidence and file meanings](evidence/README.md)", - "", - "Snapshots and transcripts were captured before cleanup. Diagnostics include cleanup of every tracked runtime; closure after a completed scenario is not an additional user turn.", - "", - ].join("\n"); - await Bun.write(`${directory}/README.md`, report); - return { output, transcript: rendered, report, failure }; - }, - ); -} - -/** Run one named example, retaining evidence before reporting an assertion failure. */ -export async function runFixture( - scenario: Scenario, - options: { artifactDirectory: string; compareBaseline?: boolean }, -) { - await logging(); - const result = await executeScenario( - scenario, - options.artifactDirectory, - crypto.randomUUID(), - options.compareBaseline ?? true, - ); - if (result.failure) - throw new Error( - `Scenario ${scenario.name} failed. Report: ${resolve(options.artifactDirectory)}/README.md\n${JSON.stringify(result.failure)}`, - ); - return result; -} - -export async function runFixtures( - options: { update?: boolean; artifactDirectory?: string; version?: 1 | 2 } = {}, -) { - await logging(); - const version = options.version; - const selected = scenarios.filter( - (scenario) => version === undefined || scenario.version === version, - ); - const runId = crypto.randomUUID(); - const directory = `${options.artifactDirectory ?? defaultArtifacts}/${runId}`; - await Bun.write( - `${directory}/run.json`, - json({ - runId, - fixtureVersion: version, - directory: resolve(directory), - startedAt: new Date().toISOString(), - }), - ); - if (import.meta.main) - console.log(`Session examples and diagnostics: ${resolve(directory)}/README.md`); - const results: Awaited>[] = []; - for (const scenario of selected) - results.push( - await executeScenario(scenario, `${directory}/${scenario.name}`, runId, !options.update), - ); - const structured = json(results.map((result) => result.output)); - const markdown = results.map((result) => result.transcript).join("\n---\n\n"); - await Bun.write(`${directory}/actual.json`, structured); - await Bun.write(`${directory}/actual.md`, markdown); - await Bun.write( - `${directory}/README.md`, - [ - `# Session examples — ${version ? `group ${version}` : "all scenarios"}`, - "", - "Start with conversation and tools for ordinary usage; persistence and branching explain failure and recovery guarantees. Each report links to executable TypeScript, checks, the transcript, and exact evidence. Providers are simulated; the runtime is real.", - "", - ...selected.map( - (scenario, index) => - `- **${results[index]!.failure ? "FAIL" : "PASS"}** [${scenario.name}](${scenario.name}/README.md) — ${scenario.purpose}`, - ), - "", - "Aggregate actual.json is machine evidence; actual.md combines transcripts. run.json supplies the nondeterministic run ID used in diagnostics. Per-scenario evidence is captured before cleanup; logs include cleanup.", - "", - ].join("\n"), - ); - const failures = results.filter((result) => result.failure); - if (failures.length) - throw new Error( - `Session examples failed: ${failures.map((result) => `${result.output.name}: ${result.failure?.message ?? "Unknown failure"}`).join("\n")}. Read ${resolve(directory)}/README.md`, - ); - if (options.update) { - for (const group of [1, 2]) { - const suffix = group === 2 ? "-v2" : ""; - const path = new URL(`expected${suffix}.json`, fixtureRoot); - const selectedResults = results.filter( - (_result, index) => selected[index]?.version === group, - ); - if (selectedResults.length) { - // Approved baselines are review artifacts; write them in repository style so a diff shows - // only behaviour changes and `format:check` passes. - const file = Bun.fileURLToPath(path); - const config = await resolveConfig(file); - const text = await format(json(selectedResults.map((result) => result.output)), { - ...config, - filepath: file, - }); - await Bun.write(path, text); - } - } - } - - return { - structured, - markdown, - count: selected.length, - artifactDirectory: resolve(directory), - runId, - }; -} -if (import.meta.main) { - const args = Bun.argv.slice(2); - if (args.some((arg) => arg !== "--update" && arg !== "--v2")) - throw new Error( - "Usage: bun run packages/core-agent/session/fixture-runner.ts [--v2] [--update]", - ); - const result = await runFixtures({ - update: args.includes("--update"), - version: args.includes("--v2") ? 2 : undefined, - }); - console.log( - `${result.count} session examples ${args.includes("--update") ? "updated" : "passed"}.`, - ); -} diff --git a/packages/core-agent/session/fixtures/README.md b/packages/core-agent/session/fixtures/README.md deleted file mode 100644 index 0c15e627..00000000 --- a/packages/core-agent/session/fixtures/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Session tests as executable examples - -Start with [usage.ts](usage.ts). It shows the exported package API with explicit configuration, -a local completion port, typed tool definitions, permission handling, and close/restore. Read -`conversationUsage` first, then `toolUsage`: admission means the input was persisted; settlement -means the turn finished. The host runs the tool loop. The caller does not implement another loop. - -Run just those examples: - -```sh -bun test packages/core-agent/session/session-fixtures.test.ts -t usage- -``` - -Each test prints its scenario name and purpose. Open -`.session-artifacts/examples/v2/usage-conversation-and-restore/README.md` for the executed checks, -conversation, and links to logs and raw evidence. Run the complete inspector with: - -```sh -bun run packages/core-agent/session/fixture-runner.ts -bun run packages/core-agent/session/fixture-runner.ts --v2 -``` - -The inspector prints a unique run directory under `.session-artifacts/latest/`. Open its README.md. -The default runs both scenario groups; --v2 selects the policy group. -The runner fails if any scenario or assertion fails, after writing its evidence. A **PASS** for a -refusal or failed-append example means the expected refusal/failure was observed; it does not -claim the user turn completed successfully. - -## Where the design intent lives - -| Source | Questions it answers | -| ---------------------------------- | -------------------------------------------------------------------------------------------------- | -| [usage.ts](usage.ts) | How do I configure, prompt, approve tools, close, and restore a session? | -| [conversation.ts](conversation.ts) | What happens on a second input, handoff, policy change, or exhausted allowance? | -| [tools.ts](tools.ts) | What does approval gate? What happens when a tool fails or finishes after cancellation? | -| [persistence.ts](persistence.ts) | What happens when storage rejects an append, loses a receipt, or work is interrupted? | -| [branching.ts](branching.ts) | What is inherited, replaced, or excluded when a child session is published? | -| [providers.ts](providers.ts) | How do attachments, continuations, provider switching, and streaming affect requests and journals? | - -Every scenario has a purpose and explicit checks in its body. `f.action` labels an action about -to be attempted; it does not certify success. `f.check` compares observed and expected values, -records the named result, and throws on failure. No assertion is inferred from a log message. -Use direct public session calls in new examples. Do not add an operation enum or interpreter. - -## What is real and what is simulated - -The session runtime, host, policies, codecs, reducers, receipt gates and provider profiles are -real. `support.ts` supplies deterministic IDs, process-local memory persistence, scripted -completion/permission responses, and controllable deferred work. Provider examples exercise -real encode/transport/decode against canned responses in `wire-responses.ts`; they do not call -external services. For production, supply your persistence adapter and provider bindings. These -examples do not prove disk durability or compatibility with a live provider deployment. - -Historical regression examples use agent IDs `a` and `b` to preserve exact evidence; reports -name them Primary assistant and Handoff specialist. Their echo tool returns its input. -`defer:name` pauses a tool until `f.release(name)`; `error:message` throws the scripted error. -Those are test-double controls, not runtime tool features. The introductory examples define -their own descriptive agent and tool names without those controls. - -## Evidence versus explanation - -- Scenario `README.md`: intent, setup, attempted actions, executed assertions, and outcome. -- `transcript.md`: readable committed conversation, tool calls/results, permissions, and recovery. -- `diagnostics.log` / `diagnostics.jsonl`: actual runtime events, joined by session/turn/child IDs. -- `evidence/`: exact requests, outputs, snapshots, journals, assertion values, and ID mappings. - Its README defines every file and the pre-cleanup capture boundary. - -The JSON baselines describe the current schema. The runner compares requests, -results and durable states (including journal records). Obsolete input-interpreter metadata was removed. New introductory examples use named assertions. Human formatting has focused tests -rather than a second copy of every scenario as approved Markdown. - -When adding a scenario, explain the guarantee, show the public calls, assert the meaningful -outcome and side effects, and read its generated report. Cover failure as well as success when -it changes the contract. If demonstrating a feature requires reaching around a public boundary, -raise that contract limitation before introducing a workaround. - -Completion usage appears in the streaming baselines as `effect` records (`effect: "usage"`) staged -right after their `model_settled` (after the terminal record when that step ended the turn), and as `lastCompletionUsage`. Counts come from the existing scripted response frames; tool -ordering, outcomes and dispatch counts are unchanged. The fixture transport assigns deterministic -`fixture-http-N` request IDs so exact journal comparisons remain meaningful. Production transports -default to unique UUIDs; retained evidence is never rewritten to hide random IDs. diff --git a/packages/core-agent/session/fixtures/branching.ts b/packages/core-agent/session/fixtures/branching.ts deleted file mode 100644 index 2eafa0a1..00000000 --- a/packages/core-agent/session/fixtures/branching.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { createSession, restoreSession } from "../session-runtime.ts"; -import { until } from "../test-support.ts"; -import type { Scenario } from "./support.ts"; - -export const forkV1: Scenario = { - name: "fork", - version: 1, - group: "branching", - purpose: - "Create an independent child with inherited conversation context, then change only its instructions.", - source: "packages/core-agent/session/fixtures/branching.ts#forkV1", - dependencies: { - completions: [ - { - kind: "answer", - text: "Shared", - }, - { - kind: "answer", - text: "Child", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'First' to root."); - const turn1 = root.input("First"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Fork root into child."); - const child = f.track("child", await root.fork()); - f.action("Update child system; expect accepted."); - const receipt3 = await child.updateSystem(["Independent"]); - f.check("System update admission", receipt3.kind, "accepted"); - f.record({ op: "system", session: "child", receipt: receipt3 }); - f.action("Submit 'Branch' to child."); - const turn4 = child.input("Branch"); - const admission4 = await turn4.accepted; - f.record({ op: "input", session: "child", accepted: admission4 }); - f.check("Input admission is accepted", admission4.kind, "accepted"); - const result4 = await turn4.settled; - f.record({ session: "child", terminal: result4 }); - f.action("Restore child as restored from its journal, without model work."); - const requestsBeforeRestore5 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), child.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore5, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - child.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "child terminal outcomes", - child.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check("Provider request count", f.requests.length, 2); - f.check( - "Changing child instructions leaves the parent unchanged", - root.snapshot.durable.systemInputs, - [], - ); - f.check("The child owns its new instructions", child.snapshot.durable.systemInputs, [ - "Independent", - ]); - }, -}; - -export const activeTurnForkV1: Scenario = { - name: "active-turn-fork", - version: 1, - group: "branching", - purpose: - "Request a fork during an active turn; capture that terminal boundary, excluding the next parent input.", - source: "packages/core-agent/session/fixtures/branching.ts#activeTurnForkV1", - dependencies: { - completions: [ - { - defer: "answer", - value: { - kind: "answer", - text: "Boundary", - }, - }, - { - kind: "answer", - text: "Parent next", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'First' to root."); - const turn1 = root.input("First"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 requests before the next action."); - await until(() => f.requests.length === 1); - f.action("Fork root into child."); - const childPending = root.fork(); - await until(() => root.snapshot.durable.conversation.pending.length > 0); - f.action("Update root system; expect busy."); - const receipt4 = await root.updateSystem(["Busy"]); - f.check("System update admission", receipt4.kind, "busy"); - f.record({ op: "system", session: "root", receipt: receipt4 }); - f.action("Release deferred answer work."); - await f.release("answer"); - f.action("Settle the active turn in root."); - f.record({ session: "root", terminal: await turn1.settled }); - f.action("Submit 'Next' to root."); - const turn7 = root.input("Next"); - const admission7 = await turn7.accepted; - f.record({ op: "input", session: "root", accepted: admission7 }); - f.check("Input admission is accepted", admission7.kind, "accepted"); - const result7 = await turn7.settled; - f.record({ session: "root", terminal: result7 }); - f.action("Wait for the queued child branch to publish."); - const child = f.track("child", await childPending); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "child terminal outcomes", - child.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - f.check( - "The queued child excludes the next parent input", - JSON.stringify(child.snapshot.durable.conversation.log).includes("Next"), - false, - ); - f.check( - "The child captures the completed boundary answer", - JSON.stringify(child.snapshot.durable.conversation.log).includes("Boundary"), - true, - ); - }, -}; - -export const successiveCompactionEmptyResetV1: Scenario = { - name: "successive-compaction-empty-reset", - version: 1, - group: "branching", - purpose: - "Replace context through successive child sessions; an empty replacement deliberately resets context.", - source: "packages/core-agent/session/fixtures/branching.ts#successiveCompactionEmptyResetV1", - dependencies: { - completions: [ - { - kind: "answer", - text: "Source", - }, - { - kind: "answer", - text: "After compaction", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Original' to root."); - const turn1 = root.input("Original"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Compact root into compact1."); - const compact1 = f.track( - "compact1", - await root.compact([ - { - role: "user", - text: "Summary one", - }, - ]), - ); - f.action("Submit 'More' to compact1."); - const turn3 = compact1.input("More"); - const admission3 = await turn3.accepted; - f.record({ op: "input", session: "compact1", accepted: admission3 }); - f.check("Input admission is accepted", admission3.kind, "accepted"); - const result3 = await turn3.settled; - f.record({ session: "compact1", terminal: result3 }); - f.action("Compact compact1 into compact2."); - const compact2 = f.track( - "compact2", - await compact1.compact([ - { - role: "user", - text: "Summary two", - }, - ]), - ); - f.action("Compact compact2 into reset."); - const reset = f.track("reset", await compact2.compact([])); - f.action("Restore reset as restored from its journal, without model work."); - const requestsBeforeRestore6 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), reset.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore6, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - reset.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "compact1 terminal outcomes", - compact1.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "compact2 terminal outcomes", - compact2.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check( - "reset terminal outcomes", - reset.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check("Provider request count", f.requests.length, 2); - f.check( - "An empty replacement explicitly clears inherited context", - reset.snapshot.durable.conversation.context, - [], - ); - }, -}; - -export const invalidContextV1: Scenario = { - name: "invalid-context", - version: 1, - group: "branching", - purpose: "Reject an orphan tool result as compaction context before publishing a child session.", - source: "packages/core-agent/session/fixtures/branching.ts#invalidContextV1", - dependencies: { - completions: [], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Attempt compaction with an orphan tool result."); - let rejected = false; - let rejectionReason = ""; - try { - await root.compact([ - { - role: "tool", - callId: "orphan", - text: "Invalid", - }, - ]); - } catch (error) { - rejected = true; - rejectionReason = error instanceof Error ? error.message : String(error); - } - f.check("Orphan tool context is rejected", rejected, true); - f.check( - "The rejection identifies the orphan tool result", - rejectionReason.includes("orphan"), - true, - ); - f.record({ op: "invalid-context", rejected }); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check("Provider request count", f.requests.length, 0); - }, -}; - -export const contextPolicyForkV2: Scenario = { - name: "context-policy-fork", - version: 2, - group: "branching", - purpose: - "Carry committed policy and instructions into a compacted child, including a subsequent agent handoff.", - source: "packages/core-agent/session/fixtures/branching.ts#contextPolicyForkV2", - dependencies: { - format: 2, - completions: [ - { - kind: "answer", - text: "Ancestor", - }, - { - kind: "handoff", - agent: "b", - text: "Delegate", - }, - { - kind: "answer", - text: "Child answer", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Earlier' to root."); - const turn1 = root.input("Earlier"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Update root policy; expect accepted."); - const receipt2 = await root.updatePolicy({ - project: "context-only@1", - }); - f.check("Policy update admission", receipt2.kind, "accepted"); - f.record({ op: "policy", session: "root", receipt: receipt2 }); - f.action("Update root system; expect accepted."); - const receipt3 = await root.updateSystem(["Shared instruction"]); - f.check("System update admission", receipt3.kind, "accepted"); - f.record({ op: "system", session: "root", receipt: receipt3 }); - f.action("Compact root into child."); - const child = f.track( - "child", - await root.compact([ - { - role: "user", - text: "Summary", - }, - ]), - ); - f.action("Submit 'Continue' to child."); - const turn5 = child.input("Continue"); - const admission5 = await turn5.accepted; - f.record({ op: "input", session: "child", accepted: admission5 }); - f.check("Input admission is accepted", admission5.kind, "accepted"); - const result5 = await turn5.settled; - f.record({ session: "child", terminal: result5 }); - f.action("Restore child as restored from its journal, without model work."); - const requestsBeforeRestore6 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), child.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore6, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - child.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "child terminal outcomes", - child.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 3); - }, -}; diff --git a/packages/core-agent/session/fixtures/conversation.ts b/packages/core-agent/session/fixtures/conversation.ts deleted file mode 100644 index 13c656fa..00000000 --- a/packages/core-agent/session/fixtures/conversation.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { createSession, restoreSession } from "../session-runtime.ts"; -import { until } from "../test-support.ts"; -import type { Scenario } from "./support.ts"; - -export const plainConversationV1: Scenario = { - name: "plain-conversation", - version: 1, - group: "conversation", - purpose: "Send two messages, then reopen the saved session without calling the model again.", - source: "packages/core-agent/session/fixtures/conversation.ts#plainConversationV1", - dependencies: { - completions: [ - { - kind: "answer", - text: "Hello", - }, - { - kind: "answer", - text: "Again", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Hi' to root."); - const turn1 = root.input("Hi"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Submit 'Continue' to root."); - const turn2 = root.input("Continue"); - const admission2 = await turn2.accepted; - f.record({ op: "input", session: "root", accepted: admission2 }); - f.check("Input admission is accepted", admission2.kind, "accepted"); - const result2 = await turn2.settled; - f.record({ session: "root", terminal: result2 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore3 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore3, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check("Provider request count", f.requests.length, 2); - f.check( - "Restoration preserves both messages and answers", - restored.snapshot.durable.conversation.log, - root.snapshot.durable.conversation.log, - ); - }, -}; - -export const systemChangesV1: Scenario = { - name: "system-changes", - version: 1, - group: "conversation", - purpose: - "Commit shared instructions before submitting a user message; restore the same instruction version.", - source: "packages/core-agent/session/fixtures/conversation.ts#systemChangesV1", - dependencies: { - completions: [ - { - kind: "answer", - text: "Brief", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Update root system; expect accepted."); - const receipt1 = await root.updateSystem(["First", "Second"]); - f.check("System update admission", receipt1.kind, "accepted"); - f.record({ op: "system", session: "root", receipt: receipt1 }); - f.action("Submit 'Hi' to root."); - const turn2 = root.input("Hi"); - const admission2 = await turn2.accepted; - f.record({ op: "input", session: "root", accepted: admission2 }); - f.check("Input admission is accepted", admission2.kind, "accepted"); - const result2 = await turn2.settled; - f.record({ session: "root", terminal: result2 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore3 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore3, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 1); - }, -}; - -export const handoffV1: Scenario = { - name: "handoff", - version: 1, - group: "conversation", - purpose: - "Delegate from the primary assistant to a specialist while preserving shared instructions.", - source: "packages/core-agent/session/fixtures/conversation.ts#handoffV1", - dependencies: { - completions: [ - { - kind: "handoff", - agent: "b", - text: "Delegate", - }, - { - kind: "answer", - text: "From B", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Update root system; expect accepted."); - const receipt1 = await root.updateSystem(["Shared instruction"]); - f.check("System update admission", receipt1.kind, "accepted"); - f.record({ op: "system", session: "root", receipt: receipt1 }); - f.action("Submit 'Help' to root."); - const turn2 = root.input("Help"); - const admission2 = await turn2.accepted; - f.record({ op: "input", session: "root", accepted: admission2 }); - f.check("Input admission is accepted", admission2.kind, "accepted"); - const result2 = await turn2.settled; - f.record({ session: "root", terminal: result2 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore3 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore3, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const exhaustionV1: Scenario = { - name: "exhaustion", - version: 1, - group: "conversation", - purpose: - "Stop at the configured model-step allowance instead of executing an unbounded handoff loop.", - source: "packages/core-agent/session/fixtures/conversation.ts#exhaustionV1", - dependencies: { - allowance: 1, - completions: [ - { - kind: "handoff", - agent: "b", - text: "Delegate", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Help' to root."); - const turn1 = root.input("Help"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["exhausted"], - ); - f.check("Provider request count", f.requests.length, 1); - }, -}; - -export const bargeInV1: Scenario = { - name: "barge-in", - version: 1, - group: "conversation", - purpose: - "Replace in-flight model work with a new user message; ignore the old model result when it arrives.", - source: "packages/core-agent/session/fixtures/conversation.ts#bargeInV1", - dependencies: { - completions: [ - { - defer: "old", - value: { - kind: "answer", - text: "Late", - }, - }, - { - kind: "answer", - text: "New", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'First' to root."); - const turn1 = root.input("First"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 requests before the next action."); - await until(() => f.requests.length === 1); - f.action("Submit 'Second' to root."); - const turn3 = root.input("Second"); - const admission3 = await turn3.accepted; - f.record({ op: "input", session: "root", accepted: admission3 }); - f.check("Input admission is accepted", admission3.kind, "accepted"); - const result3 = await turn3.settled; - f.record({ session: "root", terminal: result3 }); - f.action("Release deferred old work."); - await f.release("old"); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - f.check( - "The late answer is absent from committed history", - JSON.stringify(root.snapshot.durable.conversation.log).includes("Late"), - false, - ); - }, -}; - -export const policyPermissionsAllowanceV2: Scenario = { - name: "policy-permissions-allowance", - version: 2, - group: "conversation", - purpose: - "Change tool visibility and step allowance at committed boundaries; zero allowance starts no model work.", - source: "packages/core-agent/session/fixtures/conversation.ts#policyPermissionsAllowanceV2", - dependencies: { - format: 2, - completions: [ - { - kind: "answer", - text: "Restricted", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Update root policy; expect accepted."); - const receipt1 = await root.updatePolicy({ - steps: 1, - tools: { - a: [], - }, - }); - f.check("Policy update admission", receipt1.kind, "accepted"); - f.record({ op: "policy", session: "root", receipt: receipt1 }); - f.action("Submit 'Go' to root."); - const turn2 = root.input("Go"); - const admission2 = await turn2.accepted; - f.record({ op: "input", session: "root", accepted: admission2 }); - f.check("Input admission is accepted", admission2.kind, "accepted"); - const result2 = await turn2.settled; - f.record({ session: "root", terminal: result2 }); - f.action("Update root policy; expect accepted."); - const receipt3 = await root.updatePolicy({ - steps: 0, - }); - f.check("Policy update admission", receipt3.kind, "accepted"); - f.record({ op: "policy", session: "root", receipt: receipt3 }); - f.action("Submit 'No allowance' to root."); - const turn4 = root.input("No allowance"); - const admission4 = await turn4.accepted; - f.record({ op: "input", session: "root", accepted: admission4 }); - f.check("Input admission is accepted", admission4.kind, "accepted"); - const result4 = await turn4.settled; - f.record({ session: "root", terminal: result4 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore5 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore5, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "exhausted"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "exhausted"], - ); - f.check("Provider request count", f.requests.length, 1); - }, -}; - -export const queueUserV2: Scenario = { - name: "queue-user", - version: 2, - group: "conversation", - purpose: - "Queue a second user input while the first model call is pending; a configuration selected meanwhile applies at the boundary before the queued input starts.", - source: "packages/core-agent/session/fixtures/conversation.ts#queueUserV2", - dependencies: { - format: 2, - policy: { - id: "queued@1", - }, - completions: [ - { - defer: "first", - value: { - kind: "answer", - text: "First", - }, - }, - { - kind: "answer", - text: "Second", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'First' to root."); - const turn1 = root.input("First"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 requests before the next action."); - await until(() => f.requests.length === 1); - f.action("Submit 'Second' to root."); - const turn3 = root.input("Second"); - const admission3 = await turn3.accepted; - f.record({ op: "input", session: "root", accepted: admission3 }); - f.check("Input admission is accepted", admission3.kind, "accepted"); - f.action( - "Select a root configuration change; expect selected (stored now, applied at the next boundary between turns).", - ); - const receipt4 = await root.updatePolicy({ - steps: 9, - }); - f.check("Policy update admission", receipt4.kind, "selected"); - f.record({ op: "policy", session: "root", receipt: receipt4 }); - f.action("Release deferred first work."); - await f.release("first"); - f.action("Settle the active turn in root."); - f.record({ session: "root", terminal: await turn3.settled }); - f.check( - "The selection applies after the first turn and before the queued input starts", - root.snapshot.durable.records - .map((record) => record.body.kind) - .filter((kind) => kind === "terminal" || kind === "policy" || kind === "dequeued"), - ["terminal", "policy", "dequeued", "terminal"], - ); - f.check("The queued turn runs with the selected steps", root.snapshot.durable.policy?.steps, 9); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore7 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore7, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const configurationChangeV2: Scenario = { - name: "configuration-change", - version: 2, - group: "conversation", - purpose: "Change committed policy while retaining the existing conversation.", - source: "packages/core-agent/session/fixtures/conversation.ts#configurationChangeV2", - dependencies: { - completions: [ - { - kind: "answer", - text: "Legacy", - }, - { - kind: "answer", - text: "Version two", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Before' to root."); - const turn1 = root.input("Before"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Update root policy; expect accepted."); - const receipt2 = await root.updatePolicy({ - id: "strict@1", - steps: 2, - }); - f.check("Policy update admission", receipt2.kind, "accepted"); - f.record({ op: "policy", session: "root", receipt: receipt2 }); - f.action("Submit 'After' to root."); - const turn3 = root.input("After"); - const admission3 = await turn3.accepted; - f.record({ op: "input", session: "root", accepted: admission3 }); - f.check("Input admission is accepted", admission3.kind, "accepted"); - const result3 = await turn3.settled; - f.record({ session: "root", terminal: result3 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore4 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore4, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; diff --git a/packages/core-agent/session/fixtures/expected-v2.json b/packages/core-agent/session/fixtures/expected-v2.json deleted file mode 100644 index 7d6a4d12..00000000 --- a/packages/core-agent/session/fixtures/expected-v2.json +++ /dev/null @@ -1,25368 +0,0 @@ -[ - { - "name": "usage-conversation-and-restore", - "requests": [ - { - "model": "example-model", - "messages": [ - { - "role": "system", - "content": "Answer the user's question." - }, - { - "role": "user", - "content": "What does a session retain?" - } - ], - "tools": [] - } - ], - "results": [ - { - "admission": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "answer": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "reviewer", - "messages": [ - { - "role": "user", - "text": "What does a session retain?" - }, - { - "role": "assistant", - "text": "The session retains committed conversation history." - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "original": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "reviewer", - "steps": 4 - }, - "log": [ - { - "agent": "reviewer", - "messages": [ - { - "role": "user", - "text": "What does a session retain?" - }, - { - "role": "assistant", - "text": "The session retains committed conversation history." - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "systemPrompt": "Answer the user's question.", - "tools": [] - } - ] - ], - "tools": [] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": [] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "reviewer", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "systemPrompt": "Answer the user's question.", - "tools": [] - } - ] - ], - "tools": [] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": [] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "What does a session retain?" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "example-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "The session retains committed conversation history." - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "reviewer", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "reopened": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "reviewer", - "steps": 4 - }, - "log": [ - { - "agent": "reviewer", - "messages": [ - { - "role": "user", - "text": "What does a session retain?" - }, - { - "role": "assistant", - "text": "The session retains committed conversation history." - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "systemPrompt": "Answer the user's question.", - "tools": [] - } - ] - ], - "tools": [] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": [] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "reviewer", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "systemPrompt": "Answer the user's question.", - "tools": [] - } - ] - ], - "tools": [] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": [] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "What does a session retain?" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "example-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "The session retains committed conversation history." - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "reviewer", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "usage-approve-tool", - "requests": [ - { - "model": "example-model", - "messages": [ - { - "role": "user", - "content": "Summarize the design note." - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "read_note", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - } - } - ] - }, - { - "model": "example-model", - "messages": [ - { - "role": "user", - "content": "Summarize the design note." - }, - { - "role": "assistant", - "content": "I will read the design note.", - "tool_calls": [ - { - "id": "read-design", - "type": "function", - "function": { - "name": "read_note", - "arguments": "{\"name\":\"design\"}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"body\":\"Keep decisions pure; persist before releasing effects.\"}", - "tool_call_id": "read-design" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "read_note", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - } - } - ] - } - ], - "results": [ - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/read-design", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/read-design", - "title": "read_note", - "name": "read_note", - "kind": "read", - "status": "pending", - "rawInput": { - "name": "design" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow read_note for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - }, - { - "result": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "reviewer", - "messages": [ - { - "role": "user", - "text": "Summarize the design note." - }, - { - "role": "assistant", - "text": "I will read the design note.", - "calls": [ - { - "id": "read-design", - "name": "read_note", - "args": { - "name": "design" - } - } - ] - }, - { - "role": "tool", - "text": "{\"body\":\"Keep decisions pure; persist before releasing effects.\"}", - "callId": "read-design" - }, - { - "role": "assistant", - "text": "The design keeps decisions pure and persists before releasing effects." - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "review": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "reviewer", - "steps": 4 - }, - "log": [ - { - "agent": "reviewer", - "messages": [ - { - "role": "user", - "text": "Summarize the design note." - }, - { - "role": "assistant", - "text": "I will read the design note.", - "calls": [ - { - "id": "read-design", - "name": "read_note", - "args": { - "name": "design" - } - } - ] - }, - { - "role": "tool", - "text": "{\"body\":\"Keep decisions pure; persist before releasing effects.\"}", - "callId": "read-design" - }, - { - "role": "assistant", - "text": "The design keeps decisions pure and persists before releasing effects." - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "tools": ["read_note"] - } - ] - ], - "tools": [ - [ - "read_note", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": ["read_note"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "reviewer", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "tools": ["read_note"] - } - ] - ], - "tools": [ - [ - "read_note", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": ["read_note"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Summarize the design note." - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "example-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "I will read the design note.", - "calls": [ - { - "id": "read-design", - "name": "read_note", - "args": { - "name": "design" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "permission_settled", - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": [ - { - "callId": "read-design", - "decision": "allow_once" - } - ] - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "read-design", - "result": { - "kind": "succeeded", - "value": { - "text": "{\"body\":\"Keep decisions pure; persist before releasing effects.\"}" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "example-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "The design keeps decisions pure and persists before releasing effects." - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "reviewer", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "usage-deny-tool", - "requests": [ - { - "model": "example-model", - "messages": [ - { - "role": "user", - "content": "Summarize the design note." - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "read_note", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - } - } - ] - }, - { - "model": "example-model", - "messages": [ - { - "role": "user", - "content": "Summarize the design note." - }, - { - "role": "assistant", - "content": "I will read the design note.", - "tool_calls": [ - { - "id": "read-design", - "type": "function", - "function": { - "name": "read_note", - "arguments": "{\"name\":\"design\"}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"refused\":true,\"reason\":\"The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.\"}", - "tool_call_id": "read-design" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "read_note", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - } - } - ] - } - ], - "results": [ - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/read-design", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/read-design", - "title": "read_note", - "name": "read_note", - "kind": "read", - "status": "pending", - "rawInput": { - "name": "design" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow read_note for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - }, - { - "result": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "reviewer", - "messages": [ - { - "role": "user", - "text": "Summarize the design note." - }, - { - "role": "assistant", - "text": "I will read the design note.", - "calls": [ - { - "id": "read-design", - "name": "read_note", - "args": { - "name": "design" - } - } - ] - }, - { - "role": "tool", - "text": "{\"refused\":true,\"reason\":\"The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.\"}", - "callId": "read-design" - }, - { - "role": "assistant", - "text": "I could not read the design note; permission was refused." - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "review": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "reviewer", - "steps": 4 - }, - "log": [ - { - "agent": "reviewer", - "messages": [ - { - "role": "user", - "text": "Summarize the design note." - }, - { - "role": "assistant", - "text": "I will read the design note.", - "calls": [ - { - "id": "read-design", - "name": "read_note", - "args": { - "name": "design" - } - } - ] - }, - { - "role": "tool", - "text": "{\"refused\":true,\"reason\":\"The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.\"}", - "callId": "read-design" - }, - { - "role": "assistant", - "text": "I could not read the design note; permission was refused." - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "tools": ["read_note"] - } - ] - ], - "tools": [ - [ - "read_note", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": ["read_note"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "reviewer", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "reviewer", - { - "model": "example-model", - "successors": [], - "tools": ["read_note"] - } - ] - ], - "tools": [ - [ - "read_note", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "reviewer": ["read_note"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Summarize the design note." - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "example-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "I will read the design note.", - "calls": [ - { - "id": "read-design", - "name": "read_note", - "args": { - "name": "design" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "permission_settled", - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": [ - { - "callId": "read-design", - "decision": "reject_once" - } - ] - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "read-design", - "result": { - "kind": "failed", - "error": { - "message": "Permission refused by the user", - "classification": "permission_refused", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/3/read-design", - "kind": "tool", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "toolName": "read_note", - "callId": "read-design" - }, - "phase": "permission", - "details": { - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/read-design", - "permissionChildId": "00000000-0000-4000-8000-000000000001/turn/1/2" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "example-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "I could not read the design note; permission was refused." - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "reviewer", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "policy-permissions-allowance", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [] - } - ], - "results": [ - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "revision": 3 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Restricted" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000005", - "revision": 6 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "revision": 8 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "No allowance" - } - ], - "outcome": { - "kind": "exhausted" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 0 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Restricted" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "No allowance" - } - ], - "outcome": { - "kind": "exhausted" - } - } - ], - "allowance": 0, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 2, - "steps": 0, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": [], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 1, - "steps": 1, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": [], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Restricted" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 2, - "steps": 0, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": [], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "No allowance" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "exhausted" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 0 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Restricted" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "No allowance" - } - ], - "outcome": { - "kind": "exhausted" - } - } - ], - "allowance": 0, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 2, - "steps": 0, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": [], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 1, - "steps": 1, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": [], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Restricted" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 2, - "steps": 0, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": [], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "No allowance" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "exhausted" - } - } - } - ] - } - } - }, - { - "name": "queue-user", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - }, - { - "role": "assistant", - "content": "First" - }, - { - "role": "user", - "content": "Second" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "revision": 3 - } - } - }, - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "selected", - "id": "00000000-0000-4000-8000-000000000004", - "policy": { - "id": "queued@1", - "version": 1, - "steps": 9, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Second" - }, - { - "role": "assistant", - "text": "Second" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 9 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "First" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Second" - }, - { - "role": "assistant", - "text": "Second" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 9, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 1, - "steps": 9, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 0, - "steps": 4, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "queued", - "inputId": "00000000-0000-4000-8000-000000000003", - "text": "Second", - "policyVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "First" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "policy", - "policy": { - "id": "queued@1", - "version": 1, - "steps": 9, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "dequeued", - "inputId": "00000000-0000-4000-8000-000000000003", - "policyVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Second" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 9 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "First" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Second" - }, - { - "role": "assistant", - "text": "Second" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 9, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 1, - "steps": 9, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 0, - "steps": 4, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "queued", - "inputId": "00000000-0000-4000-8000-000000000003", - "text": "Second", - "policyVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "First" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "policy", - "policy": { - "id": "queued@1", - "version": 1, - "steps": 9, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "dequeued", - "inputId": "00000000-0000-4000-8000-000000000003", - "policyVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Second" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "queue-recovery", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "revision": 3 - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "turn": { - "status": "awaiting_model", - "turn": { - "id": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1, - "agent": "a", - "steps": 3, - "messages": [ - { - "role": "user", - "text": "First" - } - ], - "view": { - "kind": "history" - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - } - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 0, - "steps": 4, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [ - { - "inputId": "00000000-0000-4000-8000-000000000003", - "text": "Queued" - } - ], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 3, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 0, - "steps": 4, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "queued", - "inputId": "00000000-0000-4000-8000-000000000003", - "text": "Queued", - "policyVersion": 0 - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_model", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 0, - "steps": 4, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 6, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "queued@1", - "version": 0, - "steps": 4, - "admission": "queue-user", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "queued", - "inputId": "00000000-0000-4000-8000-000000000003", - "text": "Queued", - "policyVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/0", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "recovery", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "reason": "Interrupted session; external effects were not replayed" - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/1", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_model", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/2", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "input_cancelled", - "inputId": "00000000-0000-4000-8000-000000000003", - "reason": "Interrupted session; external effects were not replayed" - } - } - ] - } - } - }, - { - "name": "tool-error-continue", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - }, - { - "role": "assistant", - "content": "Work", - "tool_calls": [ - { - "id": "bad", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"error:broken\"}" - } - }, - { - "id": "ok", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"ok\"}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"error\":\"broken\"}", - "tool_call_id": "bad" - }, - { - "role": "tool", - "content": "ok", - "tool_call_id": "ok" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Work", - "calls": [ - { - "id": "bad", - "name": "echo", - "args": { - "text": "error:broken" - } - }, - { - "id": "ok", - "name": "echo", - "args": { - "text": "ok" - } - } - ] - }, - { - "role": "tool", - "text": "{\"error\":\"broken\"}", - "callId": "bad" - }, - { - "role": "tool", - "text": "ok", - "callId": "ok" - }, - { - "role": "assistant", - "text": "Recovered" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Work", - "calls": [ - { - "id": "bad", - "name": "echo", - "args": { - "text": "error:broken" - } - }, - { - "id": "ok", - "name": "echo", - "args": { - "text": "ok" - } - } - ] - }, - { - "role": "tool", - "text": "{\"error\":\"broken\"}", - "callId": "bad" - }, - { - "role": "tool", - "text": "ok", - "callId": "ok" - }, - { - "role": "assistant", - "text": "Recovered" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "tolerant@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "return-error-and-continue", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "tolerant@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "return-error-and-continue", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Work", - "calls": [ - { - "id": "bad", - "name": "echo", - "args": { - "text": "error:broken" - } - }, - { - "id": "ok", - "name": "echo", - "args": { - "text": "ok" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "bad", - "result": { - "kind": "failed", - "error": { - "message": "broken", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2/bad", - "kind": "tool", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "toolName": "echo", - "callId": "bad" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "broken" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "ok", - "result": { - "kind": "succeeded", - "value": { - "text": "ok" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Recovered" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Work", - "calls": [ - { - "id": "bad", - "name": "echo", - "args": { - "text": "error:broken" - } - }, - { - "id": "ok", - "name": "echo", - "args": { - "text": "ok" - } - } - ] - }, - { - "role": "tool", - "text": "{\"error\":\"broken\"}", - "callId": "bad" - }, - { - "role": "tool", - "text": "ok", - "callId": "ok" - }, - { - "role": "assistant", - "text": "Recovered" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "tolerant@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "return-error-and-continue", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "tolerant@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "return-error-and-continue", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Work", - "calls": [ - { - "id": "bad", - "name": "echo", - "args": { - "text": "error:broken" - } - }, - { - "id": "ok", - "name": "echo", - "args": { - "text": "ok" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "bad", - "result": { - "kind": "failed", - "error": { - "message": "broken", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2/bad", - "kind": "tool", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "toolName": "echo", - "callId": "bad" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "broken" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "ok", - "result": { - "kind": "succeeded", - "value": { - "text": "ok" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Recovered" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "abort-tools-on-user", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Old" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Old" - }, - { - "role": "assistant", - "content": "Work", - "tool_calls": [ - { - "id": "fast", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"fast\"}" - } - } - ] - }, - { - "role": "tool", - "content": "fast", - "tool_call_id": "fast" - }, - { - "role": "user", - "content": "New" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "revision": 6 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "New" - }, - { - "role": "assistant", - "text": "New turn" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Old" - }, - { - "role": "assistant", - "text": "Work", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - }, - { - "role": "tool", - "text": "fast", - "callId": "fast" - } - ], - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "New" - }, - { - "role": "assistant", - "text": "New turn" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "abort-tools-on-user", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 11, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "abort-tools-on-user", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Old" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Work", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "queued", - "inputId": "00000000-0000-4000-8000-000000000005", - "text": "New", - "policyVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "abort" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "cancelled" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "dequeued", - "inputId": "00000000-0000-4000-8000-000000000005", - "policyVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "New turn" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 11, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Old" - }, - { - "role": "assistant", - "text": "Work", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - }, - { - "role": "tool", - "text": "fast", - "callId": "fast" - } - ], - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "New" - }, - { - "role": "assistant", - "text": "New turn" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "abort-tools-on-user", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 11, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "abort-tools-on-user", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Old" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Work", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "queued", - "inputId": "00000000-0000-4000-8000-000000000005", - "text": "New", - "policyVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "abort" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "cancelled" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/dequeue/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "dequeued", - "inputId": "00000000-0000-4000-8000-000000000005", - "policyVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "New turn" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 11, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "context-policy-fork", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Earlier" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "system", - "content": "Shared instruction" - }, - { - "role": "user", - "content": "Summary" - }, - { - "role": "user", - "content": "Continue" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent B" - }, - { - "role": "system", - "content": "Shared instruction" - }, - { - "role": "user", - "content": "Summary" - }, - { - "role": "user", - "content": "Continue" - }, - { - "role": "assistant", - "content": "Delegate" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Earlier" - }, - { - "role": "assistant", - "text": "Ancestor" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "revision": 5 - } - } - }, - { - "op": "system", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "revision": 6 - } - } - }, - { - "op": "input", - "session": "child", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000007", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000009", - "revision": 2 - } - } - }, - { - "session": "child", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000007/turn/1", - "record": { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Continue" - }, - { - "role": "assistant", - "text": "Delegate" - }, - { - "role": "assistant", - "text": "Child answer" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Earlier" - }, - { - "role": "assistant", - "text": "Ancestor" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 1, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "context-only@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["Shared instruction"], - "systemVersion": 1, - "partial": [], - "revision": 7, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Earlier" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Ancestor" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 1, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "context-only@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "system", - "inputs": ["Shared instruction"], - "version": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "compact", - "id": "00000000-0000-4000-8000-000000000006", - "sessionId": "00000000-0000-4000-8000-000000000007", - "context": [ - { - "role": "user", - "text": "Summary" - } - ] - } - }, - "systemVersion": 1 - } - } - ] - }, - "child": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000007", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary" - } - ], - "turnId": "00000000-0000-4000-8000-000000000007/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000007/turn/2", - "agent": "b", - "steps": 4 - }, - "log": [ - { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Continue" - }, - { - "role": "assistant", - "text": "Delegate" - }, - { - "role": "assistant", - "text": "Child answer" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 1, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "context-only@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["Shared instruction"], - "systemVersion": 1, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000007/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000007", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary" - } - ], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": ["Shared instruction"], - "systemVersion": 1, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 1, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "context-only@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Continue" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000010/0", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000010", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000007/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000007/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "handoff", - "text": "Delegate", - "agent": "b" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011/0", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000007/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000007/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Child answer" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011/1", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000007/turn/1", - "agent": "b", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000007", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary" - } - ], - "turnId": "00000000-0000-4000-8000-000000000007/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000007/turn/2", - "agent": "b", - "steps": 4 - }, - "log": [ - { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Continue" - }, - { - "role": "assistant", - "text": "Delegate" - }, - { - "role": "assistant", - "text": "Child answer" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 1, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "context-only@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["Shared instruction"], - "systemVersion": 1, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000007/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000007", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary" - } - ], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": ["Shared instruction"], - "systemVersion": 1, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 1, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "context-only@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Continue" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000010/0", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000010", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000007/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000007/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "handoff", - "text": "Delegate", - "agent": "b" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011/0", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000007/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000007/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Child answer" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000007", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011/1", - "appendId": "00000000-0000-4000-8000-000000000007/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000007/turn/1", - "agent": "b", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "configuration-change", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Before" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Before" - }, - { - "role": "assistant", - "content": "Legacy" - }, - { - "role": "user", - "content": "After" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Before" - }, - { - "role": "assistant", - "text": "Legacy" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "revision": 5 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "revision": 6 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "After" - }, - { - "role": "assistant", - "text": "Version two" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 2 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Before" - }, - { - "role": "assistant", - "text": "Legacy" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "After" - }, - { - "role": "assistant", - "text": "Version two" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 2, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "strict@1", - "version": 1, - "steps": 2, - "admission": "reject-during-tools", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Before" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Legacy" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "policy", - "policy": { - "id": "strict@1", - "version": 1, - "steps": 2, - "admission": "reject-during-tools", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "After" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Version two" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 2 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Before" - }, - { - "role": "assistant", - "text": "Legacy" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "After" - }, - { - "role": "assistant", - "text": "Version two" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 2, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "strict@1", - "version": 1, - "steps": 2, - "admission": "reject-during-tools", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Before" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Legacy" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "policy", - "policy": { - "id": "strict@1", - "version": 1, - "steps": 2, - "admission": "reject-during-tools", - "bargeIn": false, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "After" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Version two" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "policy-lost-acknowledgement", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Once" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Once" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Once" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Once" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Once" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "thinking-continuation-branches-switch", - "requests": [ - { - "model": "test-model", - "max_tokens": 2048, - "system": [ - { - "type": "text", - "text": "Agent A" - } - ], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Think and use tools twice" - } - ] - } - ], - "tools": [ - { - "name": "echo", - "input_schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "input_schema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ], - "thinking": { - "type": "enabled", - "budget_tokens": 1024 - }, - "stream": false - }, - { - "model": "test-model", - "max_tokens": 2048, - "system": [ - { - "type": "text", - "text": "Agent A" - } - ], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Think and use tools twice" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - }, - { - "type": "tool_use", - "id": "c0", - "name": "echo", - "input": { - "text": "result-0" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "c0", - "content": "result-0" - } - ] - } - ], - "tools": [ - { - "name": "echo", - "input_schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "input_schema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ], - "thinking": { - "type": "enabled", - "budget_tokens": 1024 - }, - "stream": false - }, - { - "model": "test-model", - "max_tokens": 2048, - "system": [ - { - "type": "text", - "text": "Agent A" - } - ], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Think and use tools twice" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - }, - { - "type": "tool_use", - "id": "c0", - "name": "echo", - "input": { - "text": "result-0" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "c0", - "content": "result-0" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - }, - { - "type": "tool_use", - "id": "c1", - "name": "echo", - "input": { - "text": "result-1" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "c1", - "content": "result-1" - } - ] - } - ], - "tools": [ - { - "name": "echo", - "input_schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "input_schema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ], - "thinking": { - "type": "enabled", - "budget_tokens": 1024 - }, - "stream": false - }, - { - "model": "test-model", - "max_tokens": 2048, - "system": [ - { - "type": "text", - "text": "Agent A" - } - ], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Think and use tools twice" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - }, - { - "type": "tool_use", - "id": "c0", - "name": "echo", - "input": { - "text": "result-0" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "c0", - "content": "result-0" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - }, - { - "type": "tool_use", - "id": "c1", - "name": "echo", - "input": { - "text": "result-1" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "c1", - "content": "result-1" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-2" - }, - { - "type": "text", - "text": "Done" - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Continue inherited context" - } - ] - } - ], - "tools": [ - { - "name": "echo", - "input_schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "input_schema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ], - "thinking": { - "type": "enabled", - "budget_tokens": 1024 - }, - "stream": false - }, - { - "model": "test-model", - "max_tokens": 2048, - "system": [ - { - "type": "text", - "text": "Agent A" - } - ], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Fresh context" - } - ] - } - ], - "tools": [ - { - "name": "echo", - "input_schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "input_schema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ], - "thinking": { - "type": "enabled", - "budget_tokens": 1024 - }, - "stream": false - }, - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Think and use tools twice" - } - ] - }, - { - "role": "model", - "parts": [ - { - "functionCall": { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "id": "c0", - "name": "echo", - "response": { - "output": "result-0" - } - } - } - ] - }, - { - "role": "model", - "parts": [ - { - "functionCall": { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "id": "c1", - "name": "echo", - "response": { - "output": "result-1" - } - } - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Done" - } - ] - }, - { - "role": "user", - "parts": [ - { - "text": "Switch provider" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 0 - }, - "maxOutputTokens": 2048 - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "input", - "session": "fork", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000011", - "appendId": "00000000-0000-4000-8000-000000000011/append/00000000-0000-4000-8000-000000000013", - "revision": 2 - } - } - }, - { - "session": "fork", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000011/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Continue inherited context" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000011/turn/2", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "input", - "session": "compact", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000016", - "appendId": "00000000-0000-4000-8000-000000000016/append/00000000-0000-4000-8000-000000000018", - "revision": 2 - } - } - }, - { - "session": "compact", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000016/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Fresh context" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000016/turn/1", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000020", - "revision": 13 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000021", - "revision": 14 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch provider" - }, - { - "role": "assistant", - "text": "Google answer" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch provider" - }, - { - "role": "assistant", - "text": "Google answer" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 1, - "provider": "google-generate@1", - "thinking": "off", - "thinkingBudgetTokens": null, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 16, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Think and use tools twice" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-2" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/5" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 11, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000012/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000012", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "fork", - "id": "00000000-0000-4000-8000-000000000010", - "sessionId": "00000000-0000-4000-8000-000000000011" - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 12, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000017/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000017", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "compact", - "id": "00000000-0000-4000-8000-000000000015", - "sessionId": "00000000-0000-4000-8000-000000000016", - "context": [] - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 13, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000020/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000020", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 1, - "provider": "google-generate@1", - "thinking": "off", - "thinkingBudgetTokens": null, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 14, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000021/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000021", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Switch provider" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 15, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000022/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000022", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "provider": "google-generate@1", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Google answer" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 16, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000022/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000022", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-2" - } - ] - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 10, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Think and use tools twice" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-2" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/5" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-2" - } - ] - } - } - ] - }, - "fork": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000011", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000011/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000011/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Continue inherited context" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000011/turn/2", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "continuations": [ - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-2" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000011/turn/2", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-3" - } - ] - } - } - ], - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000011", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000011/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000011", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "agent": "a", - "sequence": 2, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "continuations": [ - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-0" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-1" - } - ] - } - }, - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-2" - } - ] - } - } - ] - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000011", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000011/append/00000000-0000-4000-8000-000000000013/0", - "appendId": "00000000-0000-4000-8000-000000000011/append/00000000-0000-4000-8000-000000000013", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Continue inherited context" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000011", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000011/append/00000000-0000-4000-8000-000000000014/0", - "appendId": "00000000-0000-4000-8000-000000000011/append/00000000-0000-4000-8000-000000000014", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000011/turn/2", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000011/turn/2", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-3" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000011/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000011", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000011/append/00000000-0000-4000-8000-000000000014/1", - "appendId": "00000000-0000-4000-8000-000000000011/append/00000000-0000-4000-8000-000000000014", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000011/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "compact": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000016", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000016/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000016/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Fresh context" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000016/turn/1", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000016", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000016/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000016", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000016", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000016", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000016/append/00000000-0000-4000-8000-000000000018/0", - "appendId": "00000000-0000-4000-8000-000000000016/append/00000000-0000-4000-8000-000000000018", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Fresh context" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000016", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000016/append/00000000-0000-4000-8000-000000000019/0", - "appendId": "00000000-0000-4000-8000-000000000016/append/00000000-0000-4000-8000-000000000019", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000016/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@2", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000016/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-4" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000016/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000016", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000016/append/00000000-0000-4000-8000-000000000019/1", - "appendId": "00000000-0000-4000-8000-000000000016/append/00000000-0000-4000-8000-000000000019", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000016/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "anthropic-messages@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000016/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "private", - "signature": "signature-4" - } - ] - } - } - ] - } - } - }, - { - "name": "markdown-attachment-restore-fork-compact", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Review this\n# DESIGN\nPinned fixture review document." - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - }, - { - "type": "function", - "function": { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Review this\n# DESIGN\nPinned fixture review document." - }, - { - "role": "assistant", - "content": "Reviewed" - }, - { - "role": "user", - "content": "Review again" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - }, - { - "type": "function", - "function": { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "# DESIGN\nPinned fixture review document." - }, - { - "role": "user", - "content": "Review replacement context" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - }, - { - "type": "function", - "function": { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review this", - "parts": [ - { - "type": "text", - "text": "Review this" - }, - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - }, - { - "role": "assistant", - "text": "Reviewed" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "input", - "session": "fork", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007", - "revision": 2 - } - } - }, - { - "session": "fork", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review again" - }, - { - "role": "assistant", - "text": "Fork review" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "input", - "session": "compact", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000010", - "appendId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000012", - "revision": 2 - } - } - }, - { - "session": "compact", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000010/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review replacement context" - }, - { - "role": "assistant", - "text": "Compact review" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review this", - "parts": [ - { - "type": "text", - "text": "Review this" - }, - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - }, - { - "role": "assistant", - "text": "Reviewed" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 6, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Review this", - "attachments": [ - { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - ] - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@1", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Reviewed" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "fork", - "id": "00000000-0000-4000-8000-000000000004", - "sessionId": "00000000-0000-4000-8000-000000000005" - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "compact", - "id": "00000000-0000-4000-8000-000000000009", - "sessionId": "00000000-0000-4000-8000-000000000010", - "context": [ - { - "role": "user", - "text": "", - "parts": [ - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - } - ] - } - }, - "systemVersion": 0 - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review this", - "parts": [ - { - "type": "text", - "text": "Review this" - }, - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - }, - { - "role": "assistant", - "text": "Reviewed" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Review this", - "attachments": [ - { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - ] - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@1", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Reviewed" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "fork": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000005/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000005/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review this", - "parts": [ - { - "type": "text", - "text": "Review this" - }, - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - }, - { - "role": "assistant", - "text": "Reviewed" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review again" - }, - { - "role": "assistant", - "text": "Fork review" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000005/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review this", - "parts": [ - { - "type": "text", - "text": "Review this" - }, - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - }, - { - "role": "assistant", - "text": "Reviewed" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "agent": "a", - "sequence": 2, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Review again" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "event": { - "type": "model_settled", - "provider": "openai-chat@1", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000005/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Fork review" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "compact": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000010", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "", - "parts": [ - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - } - ], - "turnId": "00000000-0000-4000-8000-000000000010/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000010/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Review replacement context" - }, - { - "role": "assistant", - "text": "Compact review" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000010", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000010/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000010", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000010", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "", - "parts": [ - { - "type": "blob", - "ref": { - "id": "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - "media": "text/markdown", - "bytes": 40, - "name": "DESIGN.md" - } - } - ] - } - ], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@1", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000010", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000012/0", - "appendId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000012", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Review replacement context" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000010", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000013/0", - "appendId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000013", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000010/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@1", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000010/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Compact review" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000010", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000013/1", - "appendId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000013", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000010/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "google-thinking-parallel-tools", - "requests": [ - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Think and use tools twice" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 1024 - }, - "maxOutputTokens": 16384 - } - }, - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Think and use tools twice" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-0", - "thoughtSignature": "text-sig-0" - }, - { - "functionCall": { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - "thoughtSignature": "call-sig-0-0" - }, - { - "functionCall": { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - }, - "thoughtSignature": "call-sig-0-1" - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "id": "c0-0", - "name": "echo", - "response": { - "output": "result-0-0" - } - } - }, - { - "functionResponse": { - "id": "c0-1", - "name": "echo", - "response": { - "output": "result-0-1" - } - } - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 1024 - }, - "maxOutputTokens": 16384 - } - }, - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Think and use tools twice" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-0", - "thoughtSignature": "text-sig-0" - }, - { - "functionCall": { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - "thoughtSignature": "call-sig-0-0" - }, - { - "functionCall": { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - }, - "thoughtSignature": "call-sig-0-1" - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "id": "c0-0", - "name": "echo", - "response": { - "output": "result-0-0" - } - } - }, - { - "functionResponse": { - "id": "c0-1", - "name": "echo", - "response": { - "output": "result-0-1" - } - } - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-1", - "thoughtSignature": "text-sig-1" - }, - { - "functionCall": { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - "thoughtSignature": "call-sig-1-0" - }, - { - "functionCall": { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - }, - "thoughtSignature": "call-sig-1-1" - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "id": "c1-0", - "name": "echo", - "response": { - "output": "result-1-0" - } - } - }, - { - "functionResponse": { - "id": "c1-1", - "name": "echo", - "response": { - "output": "result-1-1" - } - } - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 1024 - }, - "maxOutputTokens": 16384 - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "work-0", - "calls": [ - { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0-0", - "callId": "c0-0" - }, - { - "role": "tool", - "text": "result-0-1", - "callId": "c0-1" - }, - { - "role": "assistant", - "text": "work-1", - "calls": [ - { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1-0", - "callId": "c1-0" - }, - { - "role": "tool", - "text": "result-1-1", - "callId": "c1-1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "work-0", - "calls": [ - { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0-0", - "callId": "c0-0" - }, - { - "role": "tool", - "text": "result-0-1", - "callId": "c0-1" - }, - { - "role": "assistant", - "text": "work-1", - "calls": [ - { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1-0", - "callId": "c1-0" - }, - { - "role": "tool", - "text": "result-1-1", - "callId": "c1-1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 12, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Think and use tools twice" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-0", - "thoughtSignature": "text-sig-0" - }, - { - "functionCall": { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - "thoughtSignature": "call-sig-0-0" - }, - { - "functionCall": { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - }, - "thoughtSignature": "call-sig-0-1" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "work-0", - "calls": [ - { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0-0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0-1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-1", - "thoughtSignature": "text-sig-1" - }, - { - "functionCall": { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - "thoughtSignature": "call-sig-1-0" - }, - { - "functionCall": { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - }, - "thoughtSignature": "call-sig-1-1" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "work-1", - "calls": [ - { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1-0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1-1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000010/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000010", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 11, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "parts": [ - { - "text": "Done", - "thoughtSignature": "answer-sig" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/5" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 12, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-0", - "thoughtSignature": "text-sig-0" - }, - { - "functionCall": { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - "thoughtSignature": "call-sig-0-0" - }, - { - "functionCall": { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - }, - "thoughtSignature": "call-sig-0-1" - } - ] - } - }, - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-1", - "thoughtSignature": "text-sig-1" - }, - { - "functionCall": { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - "thoughtSignature": "call-sig-1-0" - }, - { - "functionCall": { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - }, - "thoughtSignature": "call-sig-1-1" - } - ] - } - }, - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "parts": [ - { - "text": "Done", - "thoughtSignature": "answer-sig" - } - ] - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "work-0", - "calls": [ - { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0-0", - "callId": "c0-0" - }, - { - "role": "tool", - "text": "result-0-1", - "callId": "c0-1" - }, - { - "role": "assistant", - "text": "work-1", - "calls": [ - { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1-0", - "callId": "c1-0" - }, - { - "role": "tool", - "text": "result-1-1", - "callId": "c1-1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 12, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Think and use tools twice" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-0", - "thoughtSignature": "text-sig-0" - }, - { - "functionCall": { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - "thoughtSignature": "call-sig-0-0" - }, - { - "functionCall": { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - }, - "thoughtSignature": "call-sig-0-1" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "work-0", - "calls": [ - { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0-0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0-1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-1", - "thoughtSignature": "text-sig-1" - }, - { - "functionCall": { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - "thoughtSignature": "call-sig-1-0" - }, - { - "functionCall": { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - }, - "thoughtSignature": "call-sig-1-1" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "work-1", - "calls": [ - { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1-0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1-1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000010/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000010", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 11, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "parts": [ - { - "text": "Done", - "thoughtSignature": "answer-sig" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/5" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 12, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-0", - "thoughtSignature": "text-sig-0" - }, - { - "functionCall": { - "id": "c0-0", - "name": "echo", - "args": { - "text": "result-0-0" - } - }, - "thoughtSignature": "call-sig-0-0" - }, - { - "functionCall": { - "id": "c0-1", - "name": "echo", - "args": { - "text": "result-0-1" - } - }, - "thoughtSignature": "call-sig-0-1" - } - ] - } - }, - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "private", - "thought": true - }, - { - "text": "work-1", - "thoughtSignature": "text-sig-1" - }, - { - "functionCall": { - "id": "c1-0", - "name": "echo", - "args": { - "text": "result-1-0" - } - }, - "thoughtSignature": "call-sig-1-0" - }, - { - "functionCall": { - "id": "c1-1", - "name": "echo", - "args": { - "text": "result-1-1" - } - }, - "thoughtSignature": "call-sig-1-1" - } - ] - } - }, - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "parts": [ - { - "text": "Done", - "thoughtSignature": "answer-sig" - } - ] - } - } - ] - } - } - }, - { - "name": "responses-thinking-tools", - "requests": [ - { - "model": "test-model", - "input": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Think and use tools twice" - } - ], - "store": false, - "stream": false, - "tools": [ - { - "type": "function", - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "strict": false - }, - { - "type": "function", - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - }, - "strict": false - } - ], - "include": ["reasoning.encrypted_content"], - "reasoning": { - "effort": "high" - } - }, - { - "model": "test-model", - "input": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Think and use tools twice" - }, - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - }, - { - "type": "function_call", - "call_id": "c0", - "name": "echo", - "arguments": "{\"text\":\"result-0\"}" - }, - { - "type": "function_call_output", - "call_id": "c0", - "output": "result-0" - } - ], - "store": false, - "stream": false, - "tools": [ - { - "type": "function", - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "strict": false - }, - { - "type": "function", - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - }, - "strict": false - } - ], - "include": ["reasoning.encrypted_content"], - "reasoning": { - "effort": "high" - } - }, - { - "model": "test-model", - "input": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Think and use tools twice" - }, - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - }, - { - "type": "function_call", - "call_id": "c0", - "name": "echo", - "arguments": "{\"text\":\"result-0\"}" - }, - { - "type": "function_call_output", - "call_id": "c0", - "output": "result-0" - }, - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "encrypted-1", - "summary": [] - }, - { - "type": "function_call", - "call_id": "c1", - "name": "echo", - "arguments": "{\"text\":\"result-1\"}" - }, - { - "type": "function_call_output", - "call_id": "c1", - "output": "result-1" - } - ], - "store": false, - "stream": false, - "tools": [ - { - "type": "function", - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "strict": false - }, - { - "type": "function", - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - }, - "strict": false - } - ], - "include": ["reasoning.encrypted_content"], - "reasoning": { - "effort": "high" - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@2", - "thinking": "high", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 10, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@2", - "thinking": "high", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Think and use tools twice" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "encrypted-1", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_2", - "encrypted_content": "encrypted-2", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/5" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "encrypted-1", - "summary": [] - } - ] - } - }, - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_2", - "encrypted_content": "encrypted-2", - "summary": [] - } - ] - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Think and use tools twice" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "result-0", - "callId": "c0" - }, - { - "role": "assistant", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - }, - { - "role": "tool", - "text": "result-1", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Done", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@2", - "thinking": "high", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 10, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@2", - "thinking": "high", - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Think and use tools twice" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c0", - "name": "echo", - "args": { - "text": "result-0" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c0", - "result": { - "kind": "succeeded", - "value": { - "text": "result-0" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "encrypted-1", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "result-1" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/4", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "result-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_2", - "encrypted_content": "encrypted-2", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/5" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "encrypted-1", - "summary": [] - } - ] - } - }, - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 5 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_2", - "encrypted_content": "encrypted-2", - "summary": [] - } - ] - } - } - ] - } - } - }, - { - "name": "thinking-parity-provider-switch", - "requests": [ - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "First" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 1024 - }, - "maxOutputTokens": 16384 - } - }, - { - "model": "test-model", - "input": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - }, - { - "role": "assistant", - "content": "Google answer" - }, - { - "role": "user", - "content": "Switch to Responses" - } - ], - "store": false, - "stream": false, - "tools": [ - { - "type": "function", - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "strict": false - }, - { - "type": "function", - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - }, - "strict": false - } - ], - "max_output_tokens": 16384, - "include": ["reasoning.encrypted_content"], - "reasoning": { - "effort": "high" - } - }, - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "First" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Google answer", - "thoughtSignature": "google-owned" - } - ] - }, - { - "role": "user", - "parts": [ - { - "text": "Switch to Responses" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Responses answer" - } - ] - }, - { - "role": "user", - "parts": [ - { - "text": "Switch back to Google" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 1024 - }, - "maxOutputTokens": 16384 - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Google answer", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "revision": 5 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "revision": 6 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch to Responses" - }, - { - "role": "assistant", - "text": "Responses answer", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "policy", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000007", - "revision": 9 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "revision": 10 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch back to Google" - }, - { - "role": "assistant", - "text": "Google again", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/4", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/4", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Google answer", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch to Responses" - }, - { - "role": "assistant", - "text": "Responses answer", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch back to Google" - }, - { - "role": "assistant", - "text": "Google again", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 4, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 2, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 12, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google answer", - "thoughtSignature": "google-owned" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Google answer" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 1, - "provider": "openai-responses@2", - "thinking": "high", - "thinkingBudgetTokens": null, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Switch to Responses" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Responses answer" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 2, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Switch back to Google" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 11, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google again", - "thoughtSignature": "google-second" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/3/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Google again" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 12, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google answer", - "thoughtSignature": "google-owned" - } - ] - } - }, - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google again", - "thoughtSignature": "google-second" - } - ] - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/4", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/4", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Google answer", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch to Responses" - }, - { - "role": "assistant", - "text": "Responses answer", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Switch back to Google" - }, - { - "role": "assistant", - "text": "Google again", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "generation": 1 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 4, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 2, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 12, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google answer", - "thoughtSignature": "google-owned" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Google answer" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 1, - "provider": "openai-responses@2", - "thinking": "high", - "thinkingBudgetTokens": null, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Switch to Responses" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "provider": "openai-responses@2", - "model": "test-model", - "continuation": { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Responses answer" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/configuration/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "policy", - "policy": { - "id": "default@1", - "version": 2, - "provider": "google-generate@2", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "maxOutputTokens": 16384, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 10, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Switch back to Google" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 11, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "event": { - "type": "model_settled", - "provider": "google-generate@2", - "model": "test-model", - "continuation": { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google again", - "thoughtSignature": "google-second" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/3/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Google again" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 12, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ], - "continuations": [ - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google answer", - "thoughtSignature": "google-owned" - } - ] - } - }, - { - "provider": "openai-responses@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_0", - "encrypted_content": "encrypted-0", - "summary": [] - } - ] - } - }, - { - "provider": "google-generate@2", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Google again", - "thoughtSignature": "google-second" - } - ] - } - } - ] - } - } - }, - { - "name": "stream-openai-chat-2", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Stream and use tools" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - }, - { - "type": "function", - "function": { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - } - ], - "max_completion_tokens": 2048, - "reasoning_effort": "high", - "stream": true, - "stream_options": { - "include_usage": true - } - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Stream and use tools" - }, - { - "role": "assistant", - "content": "Hello 🌍", - "tool_calls": [ - { - "id": "c1", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"Hello 🌍\"}" - } - } - ] - }, - { - "role": "tool", - "content": "Hello 🌍", - "tool_call_id": "c1" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - }, - { - "type": "function", - "function": { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - } - ], - "max_completion_tokens": 2048, - "reasoning_effort": "high", - "stream": true, - "stream_options": { - "include_usage": true - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@2", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "completion_tokens": 4 - }, - "source": { - "provider": "openai-chat@2", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-chat@2", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@2", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "completion_tokens": 4 - }, - "source": { - "provider": "openai-chat@2", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-chat@2", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "completion_tokens": 4 - }, - "source": { - "provider": "openai-chat@2", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-chat@2", - "httpRequestId": "fixture-http-2" - } - } - } - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@2", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "completion_tokens": 4 - }, - "source": { - "provider": "openai-chat@2", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-chat@2", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@2", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "completion_tokens": 4 - }, - "source": { - "provider": "openai-chat@2", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-chat@2", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "completion_tokens": 4 - }, - "source": { - "provider": "openai-chat@2", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-chat@2", - "httpRequestId": "fixture-http-2" - } - } - } - } - } - }, - { - "name": "stream-anthropic-messages-3", - "requests": [ - { - "model": "test-model", - "max_tokens": 2048, - "system": [ - { - "type": "text", - "text": "Agent A" - } - ], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Stream and use tools" - } - ] - } - ], - "tools": [ - { - "name": "echo", - "input_schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "input_schema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ], - "thinking": { - "type": "enabled", - "budget_tokens": 1024 - }, - "stream": true - }, - { - "model": "test-model", - "max_tokens": 2048, - "system": [ - { - "type": "text", - "text": "Agent A" - } - ], - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Stream and use tools" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - }, - { - "type": "text", - "text": "Hello 🌍" - }, - { - "type": "tool_use", - "id": "c1", - "name": "echo", - "input": { - "text": "Hello 🌍" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "c1", - "content": "Hello 🌍" - } - ] - } - ], - "tools": [ - { - "name": "echo", - "input_schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "input_schema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ], - "thinking": { - "type": "enabled", - "budget_tokens": 1024 - }, - "stream": true - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@3", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "inputTokens": 1, - "outputTokens": 4, - "totalTokens": 5, - "native": { - "input_tokens": 1, - "output_tokens": 4 - }, - "source": { - "provider": "anthropic-messages@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "anthropic-messages@3", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@3", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "inputTokens": 1, - "outputTokens": 4, - "totalTokens": 5, - "native": { - "input_tokens": 1, - "output_tokens": 4 - }, - "source": { - "provider": "anthropic-messages@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "anthropic-messages@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "continuations": [ - { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - }, - { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "inputTokens": 1, - "outputTokens": 4, - "totalTokens": 5, - "native": { - "input_tokens": 1, - "output_tokens": 4 - }, - "source": { - "provider": "anthropic-messages@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "anthropic-messages@3", - "httpRequestId": "fixture-http-2" - } - } - } - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "anthropic-messages@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@3", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "inputTokens": 1, - "outputTokens": 4, - "totalTokens": 5, - "native": { - "input_tokens": 1, - "output_tokens": 4 - }, - "source": { - "provider": "anthropic-messages@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "anthropic-messages@3", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "anthropic-messages@3", - "model": "test-model", - "continuation": { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "inputTokens": 1, - "outputTokens": 4, - "totalTokens": 5, - "native": { - "input_tokens": 1, - "output_tokens": 4 - }, - "source": { - "provider": "anthropic-messages@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "anthropic-messages@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "continuations": [ - { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - }, - { - "provider": "anthropic-messages@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "blocks": [ - { - "type": "thinking", - "thinking": "Considering", - "signature": "signature" - } - ] - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "inputTokens": 1, - "outputTokens": 4, - "totalTokens": 5, - "native": { - "input_tokens": 1, - "output_tokens": 4 - }, - "source": { - "provider": "anthropic-messages@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "anthropic-messages@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - } - }, - { - "name": "stream-google-generate-3", - "requests": [ - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Stream and use tools" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 1024 - }, - "maxOutputTokens": 2048 - } - }, - { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Stream and use tools" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍" - }, - { - "functionCall": { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - }, - "thoughtSignature": "signature" - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "id": "c1", - "name": "echo", - "response": { - "output": "Hello 🌍" - } - } - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Agent A" - } - ] - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "echo", - "parametersJsonSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - }, - { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parametersJsonSchema": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - ] - } - ], - "generationConfig": { - "thinkingConfig": { - "thinkingBudget": 1024 - }, - "maxOutputTokens": 2048 - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@3", - "model": "test-model", - "continuation": { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍" - }, - { - "functionCall": { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - }, - "thoughtSignature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "candidatesTokenCount": 4 - }, - "source": { - "provider": "google-generate@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "google-generate@3", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@3", - "model": "test-model", - "continuation": { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍", - "thoughtSignature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "candidatesTokenCount": 4 - }, - "source": { - "provider": "google-generate@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "google-generate@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "continuations": [ - { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍" - }, - { - "functionCall": { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - }, - "thoughtSignature": "signature" - } - ] - } - }, - { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍", - "thoughtSignature": "signature" - } - ] - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "candidatesTokenCount": 4 - }, - "source": { - "provider": "google-generate@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "google-generate@3", - "httpRequestId": "fixture-http-2" - } - } - } - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "google-generate@3", - "thinking": "budget", - "thinkingBudgetTokens": 1024, - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@3", - "model": "test-model", - "continuation": { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍" - }, - { - "functionCall": { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - }, - "thoughtSignature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "candidatesTokenCount": 4 - }, - "source": { - "provider": "google-generate@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "google-generate@3", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "google-generate@3", - "model": "test-model", - "continuation": { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍", - "thoughtSignature": "signature" - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "candidatesTokenCount": 4 - }, - "source": { - "provider": "google-generate@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "google-generate@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "continuations": [ - { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍" - }, - { - "functionCall": { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - }, - "thoughtSignature": "signature" - } - ] - } - }, - { - "provider": "google-generate@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "parts": [ - { - "text": "Considering", - "thought": true - }, - { - "text": "Hello " - }, - { - "text": "🌍", - "thoughtSignature": "signature" - } - ] - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "candidatesTokenCount": 4 - }, - "source": { - "provider": "google-generate@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "google-generate@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - } - }, - { - "name": "stream-openai-responses-3", - "requests": [ - { - "model": "test-model", - "input": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Stream and use tools" - } - ], - "store": false, - "stream": true, - "tools": [ - { - "type": "function", - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "strict": false - }, - { - "type": "function", - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - }, - "strict": false - } - ], - "max_output_tokens": 2048, - "include": ["reasoning.encrypted_content"], - "reasoning": { - "effort": "high" - } - }, - { - "model": "test-model", - "input": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Stream and use tools" - }, - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - }, - { - "role": "assistant", - "content": "Hello 🌍" - }, - { - "type": "function_call", - "call_id": "c1", - "name": "echo", - "arguments": "{\"text\":\"Hello 🌍\"}" - }, - { - "type": "function_call_output", - "call_id": "c1", - "output": "Hello 🌍" - } - ], - "store": false, - "stream": true, - "tools": [ - { - "type": "function", - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "strict": false - }, - { - "type": "function", - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - }, - "strict": false - } - ], - "max_output_tokens": 2048, - "include": ["reasoning.encrypted_content"], - "reasoning": { - "effort": "high" - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@3", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@3", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@3", - "model": "test-model", - "continuation": { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "output_tokens": 4 - }, - "source": { - "provider": "openai-responses@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-responses@3", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@3", - "model": "test-model", - "continuation": { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "output_tokens": 4 - }, - "source": { - "provider": "openai-responses@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-responses@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "continuations": [ - { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - }, - { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "output_tokens": 4 - }, - "source": { - "provider": "openai-responses@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-responses@3", - "httpRequestId": "fixture-http-2" - } - } - } - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Stream and use tools" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ], - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - } - }, - { - "role": "tool", - "text": "Hello 🌍", - "callId": "c1" - }, - { - "role": "assistant", - "text": "Hello 🌍", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - } - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@3", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-responses@3", - "thinking": "high", - "stream": true, - "maxOutputTokens": 2048, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Stream and use tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@3", - "model": "test-model", - "continuation": { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Hello 🌍", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "Hello 🌍" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/1", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "output_tokens": 4 - }, - "source": { - "provider": "openai-responses@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-responses@3", - "httpRequestId": "fixture-http-1" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "Hello 🌍" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-responses@3", - "model": "test-model", - "continuation": { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello 🌍" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/2", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "effect", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "effect": "usage", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "output_tokens": 4 - }, - "source": { - "provider": "openai-responses@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-responses@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - ], - "continuations": [ - { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - }, - { - "provider": "openai-responses@3", - "owner": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 3 - }, - "payload": { - "items": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "signature", - "summary": [] - } - ] - } - } - ], - "lastCompletionUsage": { - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "operationId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "usage": { - "status": "reported", - "outputTokens": 4, - "native": { - "output_tokens": 4 - }, - "source": { - "provider": "openai-responses@3", - "model": "test-model", - "wireModel": "test-model", - "profile": "openai-responses@3", - "httpRequestId": "fixture-http-2" - } - } - } - } - } - }, - { - "name": "stream-truncated", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Truncated" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - }, - { - "type": "function", - "function": { - "name": "handoff_to", - "description": "Transfer the conversation to an allowed successor agent.", - "parameters": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "enum": ["a", "b"] - } - }, - "required": ["agent"], - "additionalProperties": false - } - } - } - ], - "stream": true, - "stream_options": { - "include_usage": true - } - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Truncated" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Incomplete completion stream", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream" - }, - "httpStatus": 200, - "providerRequestId": null, - "phase": "stream" - }, - "phase": "transport" - } - } - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Truncated" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Incomplete completion stream", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream" - }, - "httpStatus": 200, - "providerRequestId": null, - "phase": "stream" - }, - "phase": "transport" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "stream": true, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "stream": true, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Truncated" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@2", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "failed", - "error": { - "message": "Incomplete completion stream", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream" - }, - "httpStatus": 200, - "providerRequestId": null, - "phase": "stream" - }, - "phase": "transport" - } - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Incomplete completion stream", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream" - }, - "httpStatus": 200, - "providerRequestId": null, - "phase": "stream" - }, - "phase": "transport" - } - } - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Truncated" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Incomplete completion stream", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream" - }, - "httpStatus": 200, - "providerRequestId": null, - "phase": "stream" - }, - "phase": "transport" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "stream": true, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "provider": "openai-chat@2", - "stream": true, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Truncated" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "provider": "openai-chat@2", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "failed", - "error": { - "message": "Incomplete completion stream", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream" - }, - "httpStatus": 200, - "providerRequestId": null, - "phase": "stream" - }, - "phase": "transport" - } - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Incomplete completion stream", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream", - "cause": { - "name": "Error", - "message": "Incomplete completion stream" - }, - "httpStatus": 200, - "providerRequestId": null, - "phase": "stream" - }, - "phase": "transport" - } - } - } - } - } - ] - } - } - }, - { - "name": "permission-allow-batch", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - }, - { - "role": "assistant", - "content": "Read files", - "tool_calls": [ - { - "id": "one", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"one\"}" - } - }, - { - "id": "two", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"two\"}" - } - } - ] - }, - { - "role": "tool", - "content": "one", - "tool_call_id": "one" - }, - { - "role": "tool", - "content": "two", - "tool_call_id": "two" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/one", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/one", - "title": "echo", - "name": "echo", - "kind": "other", - "status": "pending", - "rawInput": { - "text": "one" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow echo for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - }, - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/two", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/two", - "title": "echo", - "name": "echo", - "kind": "other", - "status": "pending", - "rawInput": { - "text": "two" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow echo for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "one", - "callId": "one" - }, - { - "role": "tool", - "text": "two", - "callId": "two" - }, - { - "role": "assistant", - "text": "Read both" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "one", - "callId": "one" - }, - { - "role": "tool", - "text": "two", - "callId": "two" - }, - { - "role": "assistant", - "text": "Read both" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "permission_settled", - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": [ - { - "callId": "one", - "decision": "allow_once" - }, - { - "callId": "two", - "decision": "allow_once" - } - ] - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "one", - "result": { - "kind": "succeeded", - "value": { - "text": "one" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "two", - "result": { - "kind": "succeeded", - "value": { - "text": "two" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Read both" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "one", - "callId": "one" - }, - { - "role": "tool", - "text": "two", - "callId": "two" - }, - { - "role": "assistant", - "text": "Read both" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "permission_settled", - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": [ - { - "callId": "one", - "decision": "allow_once" - }, - { - "callId": "two", - "decision": "allow_once" - } - ] - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "one", - "result": { - "kind": "succeeded", - "value": { - "text": "one" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "two", - "result": { - "kind": "succeeded", - "value": { - "text": "two" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Read both" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "permission-reject-batch", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - }, - { - "role": "assistant", - "content": "Read files", - "tool_calls": [ - { - "id": "one", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"one\"}" - } - }, - { - "id": "two", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"two\"}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"refused\":true,\"reason\":\"The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.\"}", - "tool_call_id": "two" - }, - { - "role": "tool", - "content": "one", - "tool_call_id": "one" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/one", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/one", - "title": "echo", - "name": "echo", - "kind": "other", - "status": "pending", - "rawInput": { - "text": "one" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow echo for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - }, - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/two", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/two", - "title": "echo", - "name": "echo", - "kind": "other", - "status": "pending", - "rawInput": { - "text": "two" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow echo for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "{\"refused\":true,\"reason\":\"The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.\"}", - "callId": "two" - }, - { - "role": "tool", - "text": "one", - "callId": "one" - }, - { - "role": "assistant", - "text": "Done" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "{\"refused\":true,\"reason\":\"The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.\"}", - "callId": "two" - }, - { - "role": "tool", - "text": "one", - "callId": "one" - }, - { - "role": "assistant", - "text": "Done" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "permission_settled", - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": [ - { - "callId": "one", - "decision": "allow_once" - }, - { - "callId": "two", - "decision": "reject_once" - } - ] - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "two", - "result": { - "kind": "failed", - "error": { - "message": "Permission refused by the user", - "classification": "permission_refused", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/3/two", - "kind": "tool", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "toolName": "echo", - "callId": "two" - }, - "phase": "permission", - "details": { - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/two", - "permissionChildId": "00000000-0000-4000-8000-000000000001/turn/1/2" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "one", - "result": { - "kind": "succeeded", - "value": { - "text": "one" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "{\"refused\":true,\"reason\":\"The user refused permission for this call; it did not run. Do not retry it unchanged; ask the user how to proceed.\"}", - "callId": "two" - }, - { - "role": "tool", - "text": "one", - "callId": "one" - }, - { - "role": "assistant", - "text": "Done" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 9, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "permission_settled", - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": [ - { - "callId": "one", - "decision": "allow_once" - }, - { - "callId": "two", - "decision": "reject_once" - } - ] - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "two", - "result": { - "kind": "failed", - "error": { - "message": "Permission refused by the user", - "classification": "permission_refused", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/3/two", - "kind": "tool", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "toolName": "echo", - "callId": "two" - }, - "phase": "permission", - "details": { - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/two", - "permissionChildId": "00000000-0000-4000-8000-000000000001/turn/1/2" - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/3", - "callId": "one", - "result": { - "kind": "succeeded", - "value": { - "text": "one" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/4" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Done" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 9, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "permission-cancel-batch", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/one", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/one", - "title": "echo", - "name": "echo", - "kind": "other", - "status": "pending", - "rawInput": { - "text": "one" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow echo for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - ], - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled permission request", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "permission", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "callId": "one" - } - } - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - ], - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled permission request", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "permission", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "callId": "one" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "permission_settled", - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": [ - { - "callId": "one", - "decision": "cancelled" - } - ] - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled permission request", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "permission", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "callId": "one" - } - } - } - } - } - ] - } - } - }, - { - "name": "permission-interrupted-recovery", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "permission": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "requestId": "00000000-0000-4000-8000-000000000001/turn/1/2/one", - "toolCall": { - "toolCallId": "00000000-0000-4000-8000-000000000001/turn/1/3/one", - "title": "echo", - "name": "echo", - "kind": "other", - "status": "pending", - "rawInput": { - "text": "one" - } - }, - "options": [ - { - "optionId": "allow-once", - "name": "Allow once", - "kind": "allow_once" - }, - { - "optionId": "allow-session", - "name": "Allow echo for all arguments until session closes", - "kind": "allow_always" - }, - { - "optionId": "reject-once", - "name": "Reject", - "kind": "reject_once" - } - ] - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "turn": { - "status": "awaiting_permission", - "turn": { - "id": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 2, - "agent": "a", - "steps": 3, - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - ], - "view": { - "kind": "history" - } - }, - "child": { - "kind": "permission", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "batch": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "completion": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 3, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "permission", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_permission", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "permissions": "ask", - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Read files", - "calls": [ - { - "id": "one", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "two", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/0", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "recovery", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "reason": "Interrupted session; external effects were not replayed" - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/1", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "permission", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_permission", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - } - ] - } - } - } -] diff --git a/packages/core-agent/session/fixtures/expected.json b/packages/core-agent/session/fixtures/expected.json deleted file mode 100644 index c7dbb7ce..00000000 --- a/packages/core-agent/session/fixtures/expected.json +++ /dev/null @@ -1,10163 +0,0 @@ -[ - { - "name": "plain-conversation", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Hi" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Hi" - }, - { - "role": "assistant", - "content": "Hello" - }, - { - "role": "user", - "content": "Continue" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Hi" - }, - { - "role": "assistant", - "text": "Hello" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "revision": 5 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Continue" - }, - { - "role": "assistant", - "text": "Again" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Hi" - }, - { - "role": "assistant", - "text": "Hello" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Continue" - }, - { - "role": "assistant", - "text": "Again" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 7, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Hi" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Continue" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Again" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Hi" - }, - { - "role": "assistant", - "text": "Hello" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Continue" - }, - { - "role": "assistant", - "text": "Again" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 7, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Hi" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Hello" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Continue" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Again" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "system-changes", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "system", - "content": "First" - }, - { - "role": "system", - "content": "Second" - }, - { - "role": "user", - "content": "Hi" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "system", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "revision": 3 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Hi" - }, - { - "role": "assistant", - "text": "Brief" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Hi" - }, - { - "role": "assistant", - "text": "Brief" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["First", "Second"], - "systemVersion": 1, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "system", - "inputs": ["First", "Second"], - "version": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Hi" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Brief" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Hi" - }, - { - "role": "assistant", - "text": "Brief" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["First", "Second"], - "systemVersion": 1, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "system", - "inputs": ["First", "Second"], - "version": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Hi" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Brief" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "tools", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Run tools" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Run tools" - }, - { - "role": "assistant", - "content": "Checking", - "tool_calls": [ - { - "id": "c1", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"one\"}" - } - }, - { - "id": "c2", - "type": "function", - "function": { - "name": "echo", - "arguments": "{\"text\":\"two\"}" - } - } - ] - }, - { - "role": "tool", - "content": "one", - "tool_call_id": "c1" - }, - { - "role": "tool", - "content": "two", - "tool_call_id": "c2" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Run tools" - }, - { - "role": "assistant", - "text": "Checking", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "c2", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "one", - "callId": "c1" - }, - { - "role": "tool", - "text": "two", - "callId": "c2" - }, - { - "role": "assistant", - "text": "Checked" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Run tools" - }, - { - "role": "assistant", - "text": "Checking", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "c2", - "name": "echo", - "args": { - "text": "two" - } - } - ] - }, - { - "role": "tool", - "text": "one", - "callId": "c1" - }, - { - "role": "tool", - "text": "two", - "callId": "c2" - }, - { - "role": "assistant", - "text": "Checked" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Run tools" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Checking", - "calls": [ - { - "id": "c1", - "name": "echo", - "args": { - "text": "one" - } - }, - { - "id": "c2", - "name": "echo", - "args": { - "text": "two" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c1", - "result": { - "kind": "succeeded", - "value": { - "text": "one" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "c2", - "result": { - "kind": "succeeded", - "value": { - "text": "two" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "succeeded" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/3" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Checked" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "partial-cancellation-late-result", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - }, - { - "role": "tool", - "text": "fast", - "callId": "fast" - } - ], - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - }, - { - "role": "tool", - "text": "fast", - "callId": "fast" - } - ], - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 7, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "abort" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "cancelled" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - }, - { - "role": "tool", - "text": "fast", - "callId": "fast" - } - ], - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 7, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "abort" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "batch_settled", - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "outcome": { - "kind": "cancelled" - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "aborted", - "reason": { - "message": "User cancelled the active tool batch", - "classification": "cancelled", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - } - } - } - } - } - ] - } - } - }, - { - "name": "fork", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "system", - "content": "Independent" - }, - { - "role": "user", - "content": "First" - }, - { - "role": "assistant", - "content": "Shared" - }, - { - "role": "user", - "content": "Branch" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Shared" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "system", - "session": "child", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "child", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008", - "revision": 3 - } - } - }, - { - "session": "child", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Branch" - }, - { - "role": "assistant", - "text": "Child" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Shared" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Shared" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "fork", - "id": "00000000-0000-4000-8000-000000000004", - "sessionId": "00000000-0000-4000-8000-000000000005" - } - }, - "systemVersion": 0 - } - } - ] - }, - "child": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000005/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000005/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Shared" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Branch" - }, - { - "role": "assistant", - "text": "Child" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["Independent"], - "systemVersion": 1, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000005/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Shared" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "agent": "a", - "sequence": 2, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "system", - "inputs": ["Independent"], - "version": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Branch" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000005/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Child" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000005/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000005/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Shared" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Branch" - }, - { - "role": "assistant", - "text": "Child" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["Independent"], - "systemVersion": 1, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000005/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Shared" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "agent": "a", - "sequence": 2, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "system", - "inputs": ["Independent"], - "version": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Branch" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000005/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Child" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "active-turn-fork", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - }, - { - "role": "assistant", - "content": "Boundary" - }, - { - "role": "user", - "content": "Next" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "system", - "session": "root", - "receipt": { - "kind": "busy" - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Boundary" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "revision": 6 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Next" - }, - { - "role": "assistant", - "text": "Parent next" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/3", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/3", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Boundary" - } - ], - "outcome": { - "kind": "completed" - } - }, - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Next" - }, - { - "role": "assistant", - "text": "Parent next" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 3, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 8, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "fork", - "id": "00000000-0000-4000-8000-000000000003", - "sessionId": "00000000-0000-4000-8000-000000000004" - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Boundary" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Next" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 7, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/2/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Parent next" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 8, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000009", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "child": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000004", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000004/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000004/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Boundary" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 1, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000004", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000004/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000004", - "origin": { - "kind": "fork", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [], - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "assistant", - "text": "Boundary" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "agent": "a", - "sequence": 2, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - } - ] - } - } - }, - { - "name": "successive-compaction-empty-reset", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Original" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Summary one" - }, - { - "role": "user", - "content": "More" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Original" - }, - { - "role": "assistant", - "text": "Source" - } - ], - "outcome": { - "kind": "completed" - } - } - } - }, - { - "op": "input", - "session": "compact1", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007", - "revision": 2 - } - } - }, - { - "session": "compact1", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000005/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "More" - }, - { - "role": "assistant", - "text": "After compaction" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Original" - }, - { - "role": "assistant", - "text": "Source" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Original" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Source" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000006", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "compact", - "id": "00000000-0000-4000-8000-000000000004", - "sessionId": "00000000-0000-4000-8000-000000000005", - "context": [ - { - "role": "user", - "text": "Summary one" - } - ] - } - }, - "systemVersion": 0 - } - } - ] - }, - "compact1": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary one" - } - ], - "turnId": "00000000-0000-4000-8000-000000000005/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000005/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "More" - }, - { - "role": "assistant", - "text": "After compaction" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000005/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000005", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000001", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary one" - } - ], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000007", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "More" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000005/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000005/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "After compaction" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008/1", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000008", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000005/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000005", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000011/0", - "appendId": "00000000-0000-4000-8000-000000000005/append/00000000-0000-4000-8000-000000000011", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "compact", - "id": "00000000-0000-4000-8000-000000000009", - "sessionId": "00000000-0000-4000-8000-000000000010", - "context": [ - { - "role": "user", - "text": "Summary two" - } - ] - } - }, - "systemVersion": 0 - } - } - ] - }, - "compact2": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000010", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000005", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary two" - } - ], - "turnId": "00000000-0000-4000-8000-000000000010/turn/1", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000010/turn/1", - "agent": "a", - "steps": 4 - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 2, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000010", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000010/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000010", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000010", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000005", - "sequence": 2 - }, - "context": [ - { - "role": "user", - "text": "Summary two" - } - ], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000010", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000014/0", - "appendId": "00000000-0000-4000-8000-000000000010/append/00000000-0000-4000-8000-000000000014", - "body": { - "kind": "event", - "event": { - "type": "request", - "request": { - "kind": "compact", - "id": "00000000-0000-4000-8000-000000000012", - "sessionId": "00000000-0000-4000-8000-000000000013", - "context": [] - } - }, - "systemVersion": 0 - } - } - ] - }, - "reset": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000013", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000010", - "sequence": 1 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000013/turn/1", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000013/turn/1", - "agent": "a", - "steps": 4 - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 1, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000013", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000013/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000013", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000013", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000010", - "sequence": 1 - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000013", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000010", - "sequence": 1 - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000013/turn/1", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000013/turn/1", - "agent": "a", - "steps": 4 - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 1, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000013", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000013/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000013", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000013", - "origin": { - "kind": "compaction", - "parent": "00000000-0000-4000-8000-000000000010", - "sequence": 1 - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - } - ] - } - } - }, - { - "name": "handoff", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "system", - "content": "Shared instruction" - }, - { - "role": "user", - "content": "Help" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent B" - }, - { - "role": "system", - "content": "Shared instruction" - }, - { - "role": "user", - "content": "Help" - }, - { - "role": "assistant", - "content": "Delegate" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "system", - "session": "root", - "receipt": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "revision": 3 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Help" - }, - { - "role": "assistant", - "text": "Delegate" - }, - { - "role": "assistant", - "text": "From B" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "b", - "steps": 4 - }, - "log": [ - { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Help" - }, - { - "role": "assistant", - "text": "Delegate" - }, - { - "role": "assistant", - "text": "From B" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["Shared instruction"], - "systemVersion": 1, - "partial": [], - "revision": 6, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "system", - "inputs": ["Shared instruction"], - "version": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Help" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "handoff", - "text": "Delegate", - "agent": "b" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "From B" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "b", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "b", - "steps": 4 - }, - "log": [ - { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Help" - }, - { - "role": "assistant", - "text": "Delegate" - }, - { - "role": "assistant", - "text": "From B" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": ["Shared instruction"], - "systemVersion": 1, - "partial": [], - "revision": 6, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "system", - "inputs": ["Shared instruction"], - "version": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Help" - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "handoff", - "text": "Delegate", - "agent": "b" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "From B" - } - } - } - }, - "systemVersion": 1 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "b", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "exhaustion", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Help" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Help" - }, - { - "role": "assistant", - "text": "Delegate" - } - ], - "outcome": { - "kind": "exhausted" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "b", - "steps": 1 - }, - "log": [ - { - "agent": "b", - "messages": [ - { - "role": "user", - "text": "Help" - }, - { - "role": "assistant", - "text": "Delegate" - } - ], - "outcome": { - "kind": "exhausted" - } - } - ], - "allowance": 1, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 1, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 1, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Help" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "handoff", - "text": "Delegate", - "agent": "b" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "b", - "outcome": { - "kind": "exhausted" - } - } - } - ] - } - } - }, - { - "name": "failure", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Help" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Help" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Scripted transport failure", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Scripted transport failure" - } - } - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Help" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Scripted transport failure", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Scripted transport failure" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Help" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "failed", - "error": { - "message": "Scripted transport failure", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Scripted transport failure" - } - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Scripted transport failure", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Scripted transport failure" - } - } - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Help" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Scripted transport failure", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Scripted transport failure" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Help" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "failed", - "error": { - "message": "Scripted transport failure", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Scripted transport failure" - } - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Scripted transport failure", - "classification": "execution", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "run", - "cause": { - "name": "Error", - "message": "Scripted transport failure" - } - } - } - } - } - ] - } - } - }, - { - "name": "invalid-context", - "requests": [], - "results": [ - { - "op": "invalid-context", - "rejected": true - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "steps": 4 - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 1, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - } - ] - } - } - }, - { - "name": "interrupted-recovery", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "turn": { - "status": "executing_tools", - "turn": { - "id": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 2, - "agent": "a", - "steps": 3, - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - ], - "view": { - "kind": "history" - } - }, - "child": { - "kind": "batch", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - } - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [ - { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - ], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - }, - { - "role": "tool", - "text": "fast", - "callId": "fast" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "executing_tools", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 6, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/0", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "recovery", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "reason": "Interrupted session; external effects were not replayed" - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/1", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "executing_tools", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - } - ] - }, - "twice": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - }, - { - "role": "tool", - "text": "fast", - "callId": "fast" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "executing_tools", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 6, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "tools", - "text": "Working", - "calls": [ - { - "id": "fast", - "name": "echo", - "args": { - "text": "fast" - } - }, - { - "id": "slow", - "name": "echo", - "args": { - "text": "defer:slow" - } - } - ] - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000004", - "body": { - "kind": "tool", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "batchId": "00000000-0000-4000-8000-000000000001/turn/1/2", - "callId": "fast", - "result": { - "kind": "succeeded", - "value": { - "text": "fast" - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/0", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "recovery", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "reason": "Interrupted session; external effects were not replayed" - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 6, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/1", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/2", - "kind": "batch", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "executing_tools", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - } - ] - } - } - }, - { - "name": "interrupted-completion", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "turn": { - "status": "awaiting_model", - "turn": { - "id": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1, - "agent": "a", - "steps": 3, - "messages": [ - { - "role": "user", - "text": "Go" - } - ], - "view": { - "kind": "history" - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - } - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 2, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_model", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/0", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "recovery", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "reason": "Interrupted session; external effects were not replayed" - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/1", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_model", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - } - ] - } - } - }, - { - "name": "rejected-append", - "requests": [], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "failed", - "message": "Scripted rejected append", - "error": { - "message": "Scripted rejected append", - "classification": "persistence", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "kind": "append", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "append", - "details": { - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "expectedRevision": 1, - "attempts": 1, - "precedingFailure": null, - "observed": { - "outcome": "rejected" - }, - "originalDetails": { - "outcome": "rejected", - "expectedRevision": 1 - } - }, - "cause": { - "message": "Scripted rejected append" - } - } - } - }, - { - "session": "root", - "terminal": { - "kind": "failed", - "message": "Scripted rejected append", - "error": { - "message": "Scripted rejected append", - "classification": "persistence", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "kind": "append", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "append", - "details": { - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "expectedRevision": 1, - "attempts": 1, - "precedingFailure": null, - "observed": { - "outcome": "rejected" - }, - "originalDetails": { - "outcome": "rejected", - "expectedRevision": 1 - } - }, - "cause": { - "message": "Scripted rejected append" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "steps": 4 - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 1, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - } - ] - } - } - }, - { - "name": "lost-acknowledgement", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Only once" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Only once" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Only once" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - }, - { - "role": "assistant", - "text": "Only once" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "Only once" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - }, - { - "name": "lost-recovery-acknowledgement", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "Go" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "turn": { - "status": "awaiting_model", - "turn": { - "id": "00000000-0000-4000-8000-000000000001/turn/1", - "generation": 1, - "agent": "a", - "steps": 3, - "messages": [ - { - "role": "user", - "text": "Go" - } - ], - "view": { - "kind": "history" - } - }, - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/1" - } - }, - "log": [], - "allowance": 4, - "sequence": 1, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 2, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - } - ] - }, - "restored": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "Go" - } - ], - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_model", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 4, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Go" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/0", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "recovery", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "reason": "Interrupted session; external effects were not replayed" - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1/1", - "appendId": "recovery/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000001/turn/1", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "failed", - "error": { - "message": "Interrupted session; external effects were not replayed", - "classification": "interrupted", - "operation": { - "id": "00000000-0000-4000-8000-000000000001/turn/1/1", - "kind": "completion", - "sessionId": "00000000-0000-4000-8000-000000000001", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1" - }, - "phase": "awaiting_model", - "cause": { - "message": "Interrupted session; external effects were not replayed" - } - } - } - } - } - ] - } - } - }, - { - "name": "barge-in", - "requests": [ - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - }, - { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Agent A" - }, - { - "role": "user", - "content": "First" - }, - { - "role": "user", - "content": "Second" - } - ], - "tools": [ - { - "type": "function", - "function": { - "name": "echo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - } - } - ] - } - ], - "results": [ - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "revision": 2 - } - } - }, - { - "op": "input", - "session": "root", - "accepted": { - "kind": "accepted", - "receipt": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "revision": 3 - } - } - }, - { - "session": "root", - "terminal": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "record": { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "user", - "text": "Second" - }, - { - "role": "assistant", - "text": "New" - } - ], - "outcome": { - "kind": "completed" - } - } - } - } - ], - "states": { - "root": { - "conversation": { - "status": "open", - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "turnId": "00000000-0000-4000-8000-000000000001/turn/2", - "turn": { - "status": "idle", - "id": "00000000-0000-4000-8000-000000000001/turn/2", - "agent": "a", - "steps": 4 - }, - "log": [ - { - "agent": "a", - "messages": [ - { - "role": "user", - "text": "First" - }, - { - "role": "user", - "text": "Second" - }, - { - "role": "assistant", - "text": "New" - } - ], - "outcome": { - "kind": "completed" - } - } - ], - "allowance": 4, - "sequence": 2, - "pending": [] - }, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - }, - "pendingInputs": [], - "systemInputs": [], - "systemVersion": 0, - "partial": [], - "revision": 5, - "records": [ - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 1, - "entryId": "initialize/00000000-0000-4000-8000-000000000001/0", - "appendId": "initialize/00000000-0000-4000-8000-000000000001", - "body": { - "kind": "created", - "seed": { - "sessionId": "00000000-0000-4000-8000-000000000001", - "origin": { - "kind": "root" - }, - "context": [], - "log": [], - "agent": "a", - "sequence": 1, - "systemInputs": [], - "systemVersion": 0, - "configuration": { - "agents": [ - [ - "a", - { - "model": "test-model", - "systemPrompt": "Agent A", - "tools": ["echo"] - } - ], - [ - "b", - { - "model": "test-model", - "systemPrompt": "Agent B", - "tools": ["echo"] - } - ] - ], - "tools": [ - [ - "echo", - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - } - ] - ] - }, - "policy": { - "id": "default@1", - "version": 0, - "steps": 4, - "admission": "reject-during-tools", - "bargeIn": true, - "toolFailure": "fail-turn", - "project": "history@1", - "handoff": "handoff-slim@1", - "tools": { - "a": ["echo"], - "b": ["echo"] - } - } - } - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 2, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000002", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "First" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 3, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000003", - "body": { - "kind": "event", - "event": { - "type": "user", - "text": "Second" - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 4, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/0", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "event", - "event": { - "type": "child", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "event": { - "type": "model_settled", - "model": "test-model", - "child": { - "kind": "completion", - "id": "00000000-0000-4000-8000-000000000001/turn/1/2" - }, - "result": { - "kind": "succeeded", - "value": { - "kind": "answer", - "text": "New" - } - } - } - }, - "systemVersion": 0 - } - }, - { - "version": 2, - "sessionId": "00000000-0000-4000-8000-000000000001", - "revision": 5, - "entryId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005/1", - "appendId": "00000000-0000-4000-8000-000000000001/append/00000000-0000-4000-8000-000000000005", - "body": { - "kind": "terminal", - "turnId": "00000000-0000-4000-8000-000000000001/turn/1", - "agent": "a", - "outcome": { - "kind": "completed" - } - } - } - ] - } - } - } -] diff --git a/packages/core-agent/session/fixtures/index.ts b/packages/core-agent/session/fixtures/index.ts deleted file mode 100644 index 103627c5..00000000 --- a/packages/core-agent/session/fixtures/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - activeTurnForkV1, - contextPolicyForkV2, - forkV1, - invalidContextV1, - successiveCompactionEmptyResetV1, -} from "./branching.ts"; -import { - bargeInV1, - configurationChangeV2, - exhaustionV1, - handoffV1, - plainConversationV1, - policyPermissionsAllowanceV2, - queueUserV2, - systemChangesV1, -} from "./conversation.ts"; -import { - failureV1, - interruptedCompletionV1, - interruptedRecoveryV1, - lostAcknowledgementV1, - lostAcknowledgementV2, - lostRecoveryAcknowledgementV1, - permissionInterruptedRecoveryV2, - queueRecoveryV2, - rejectedAppendV1, -} from "./persistence.ts"; -import { - googleThinkingParallelToolsV2, - markdownAttachmentRestoreForkCompactV2, - responsesThinkingToolsV2, - streamAnthropicMessages3V2, - streamGoogleGenerate3V2, - streamOpenaiChat2V2, - streamOpenaiResponses3V2, - streamTruncatedV2, - thinkingContinuationBranchesSwitchV2, - thinkingParityProviderSwitchV2, -} from "./providers.ts"; -import type { Scenario } from "./support.ts"; -import { - abortToolsOnUserV2, - partialCancellationLateResultV1, - permissionAllowBatchV2, - permissionCancelBatchV2, - permissionRejectBatchV2, - toolErrorContinueV2, - toolsV1, -} from "./tools.ts"; -import { approveToolUsage, conversationUsage, denyToolUsage } from "./usage.ts"; - -export const scenarios: readonly Scenario[] = [ - conversationUsage, - approveToolUsage, - denyToolUsage, - plainConversationV1, - systemChangesV1, - toolsV1, - partialCancellationLateResultV1, - forkV1, - activeTurnForkV1, - successiveCompactionEmptyResetV1, - handoffV1, - exhaustionV1, - failureV1, - invalidContextV1, - interruptedRecoveryV1, - interruptedCompletionV1, - rejectedAppendV1, - lostAcknowledgementV1, - lostRecoveryAcknowledgementV1, - bargeInV1, - policyPermissionsAllowanceV2, - queueUserV2, - queueRecoveryV2, - toolErrorContinueV2, - abortToolsOnUserV2, - contextPolicyForkV2, - configurationChangeV2, - lostAcknowledgementV2, - thinkingContinuationBranchesSwitchV2, - markdownAttachmentRestoreForkCompactV2, - googleThinkingParallelToolsV2, - responsesThinkingToolsV2, - thinkingParityProviderSwitchV2, - streamOpenaiChat2V2, - streamAnthropicMessages3V2, - streamGoogleGenerate3V2, - streamOpenaiResponses3V2, - streamTruncatedV2, - permissionAllowBatchV2, - permissionRejectBatchV2, - permissionCancelBatchV2, - permissionInterruptedRecoveryV2, -]; diff --git a/packages/core-agent/session/fixtures/persistence.ts b/packages/core-agent/session/fixtures/persistence.ts deleted file mode 100644 index 472df855..00000000 --- a/packages/core-agent/session/fixtures/persistence.ts +++ /dev/null @@ -1,580 +0,0 @@ -import { createSession, restoreSession } from "../session-runtime.ts"; -import { until } from "../test-support.ts"; -import type { Scenario } from "./support.ts"; - -export const failureV1: Scenario = { - name: "failure", - version: 1, - group: "persistence", - purpose: - "Persist a transport failure as a failed turn and recover that outcome without retrying the model.", - source: "packages/core-agent/session/fixtures/persistence.ts#failureV1", - dependencies: { - completions: [ - { - error: "Scripted transport failure", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Help' to root."); - const turn1 = root.input("Help"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check("Provider request count", f.requests.length, 1); - }, -}; - -export const interruptedRecoveryV1: Scenario = { - name: "interrupted-recovery", - version: 1, - group: "persistence", - purpose: - "Reopen an interrupted tool batch, retaining its committed result and recording recovery only once.", - source: "packages/core-agent/session/fixtures/persistence.ts#interruptedRecoveryV1", - dependencies: { - completions: [ - { - kind: "tools", - text: "Working", - calls: [ - { - id: "fast", - name: "echo", - args: { - text: "fast", - }, - }, - { - id: "slow", - name: "echo", - args: { - text: "defer:slow", - }, - }, - ], - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 partial before the next action."); - await until(() => root.snapshot.durable.partial.length === 1); - f.action("Close root; in-flight work is cancelled, not resumed."); - await root.close(); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore4 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore4, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.action("Restore restored as twice from its journal, without model work."); - const requestsBeforeRestore5 = f.requests.length; - const twice = f.track( - "twice", - await restoreSession(f.restoreOptions(), restored.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore5, - ); - f.check( - "Restore keeps the session identity", - twice.snapshot.durable.conversation.sessionId, - restored.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check( - "twice terminal outcomes", - twice.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check("Provider request count", f.requests.length, 1); - f.check( - "Recovery is committed exactly once even after reopening twice", - twice.snapshot.durable.records.filter(({ body }) => body.kind === "recovery").length, - 1, - ); - }, -}; - -export const interruptedCompletionV1: Scenario = { - name: "interrupted-completion", - version: 1, - group: "persistence", - purpose: - "Close during model work and recover a failed terminal turn without resuming external work.", - source: "packages/core-agent/session/fixtures/persistence.ts#interruptedCompletionV1", - dependencies: { - completions: [ - { - defer: "answer", - value: { - kind: "answer", - text: "Unobserved", - }, - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 requests before the next action."); - await until(() => f.requests.length === 1); - f.action("Close root; in-flight work is cancelled, not resumed."); - await root.close(); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore4 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore4, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check("Provider request count", f.requests.length, 1); - }, -}; - -export const rejectedAppendV1: Scenario = { - name: "rejected-append", - version: 1, - group: "persistence", - purpose: "Reject the user-input append; do not admit a turn or call the provider.", - source: "packages/core-agent/session/fixtures/persistence.ts#rejectedAppendV1", - dependencies: { - fault: "reject-input", - completions: [], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Not accepted' to root."); - const turn1 = root.input("Not accepted"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is failed", admission1.kind, "failed"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check("Provider request count", f.requests.length, 0); - }, -}; - -export const lostAcknowledgementV1: Scenario = { - name: "lost-acknowledgement", - version: 1, - group: "persistence", - purpose: - "Lose the acknowledgement of a committed input; reconcile storage and run the model only once.", - source: "packages/core-agent/session/fixtures/persistence.ts#lostAcknowledgementV1", - dependencies: { - fault: "lose-input", - completions: [ - { - kind: "answer", - text: "Only once", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 1); - f.check( - "Receipt reconciliation does not duplicate the committed turn", - root.snapshot.durable.conversation.log.length, - 1, - ); - }, -}; - -export const lostRecoveryAcknowledgementV1: Scenario = { - name: "lost-recovery-acknowledgement", - version: 1, - group: "persistence", - purpose: - "Lose the recovery append acknowledgement; reconcile without duplicating the recovered terminal turn.", - source: "packages/core-agent/session/fixtures/persistence.ts#lostRecoveryAcknowledgementV1", - dependencies: { - fault: "lose-recovery", - completions: [ - { - defer: "answer", - value: { - kind: "answer", - text: "Unobserved", - }, - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 requests before the next action."); - await until(() => f.requests.length === 1); - f.action("Close root; in-flight work is cancelled, not resumed."); - await root.close(); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore4 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore4, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check("Provider request count", f.requests.length, 1); - }, -}; - -export const queueRecoveryV2: Scenario = { - name: "queue-recovery", - version: 2, - group: "persistence", - purpose: - "Recover an interrupted turn with queued input without silently executing either during restore.", - source: "packages/core-agent/session/fixtures/persistence.ts#queueRecoveryV2", - dependencies: { - format: 2, - policy: { - id: "queued@1", - }, - completions: [ - { - defer: "first", - value: { - kind: "answer", - text: "Unobserved", - }, - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'First' to root."); - const turn1 = root.input("First"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 requests before the next action."); - await until(() => f.requests.length === 1); - f.action("Submit 'Queued' to root."); - const turn3 = root.input("Queued"); - const admission3 = await turn3.accepted; - f.record({ op: "input", session: "root", accepted: admission3 }); - f.check("Input admission is accepted", admission3.kind, "accepted"); - f.action("Close root; in-flight work is cancelled, not resumed."); - await root.close(); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore5 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore5, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check("Provider request count", f.requests.length, 1); - }, -}; - -export const lostAcknowledgementV2: Scenario = { - name: "policy-lost-acknowledgement", - version: 2, - group: "persistence", - purpose: - "Lose the acknowledgement of a committed input; reconcile storage and run the model only once.", - source: "packages/core-agent/session/fixtures/persistence.ts#lostAcknowledgementV2", - dependencies: { - format: 2, - fault: "lose-input", - completions: [ - { - kind: "answer", - text: "Once", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 1); - f.check( - "Receipt reconciliation does not duplicate the committed turn", - root.snapshot.durable.conversation.log.length, - 1, - ); - }, -}; - -export const permissionInterruptedRecoveryV2: Scenario = { - name: "permission-interrupted-recovery", - version: 2, - group: "persistence", - purpose: "Close while permission is pending; restore without asking again or running tools.", - source: "packages/core-agent/session/fixtures/persistence.ts#permissionInterruptedRecoveryV2", - dependencies: { - format: 2, - policy: { - permissions: "ask", - }, - permissions: [ - { - defer: "permission", - }, - ], - completions: [ - { - kind: "tools", - text: "Read files", - calls: [ - { - id: "one", - name: "echo", - args: { - text: "one", - }, - }, - { - id: "two", - name: "echo", - args: { - text: "two", - }, - }, - ], - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 permissions before the next action."); - await until(() => f.permissionCount === 1); - f.action("Close root; in-flight work is cancelled, not resumed."); - await root.close(); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore4 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore4, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - [], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check("Provider request count", f.requests.length, 1); - f.check("No tool executes without batch approval", f.toolRuns.length, 0); - }, -}; diff --git a/packages/core-agent/session/fixtures/providers.ts b/packages/core-agent/session/fixtures/providers.ts deleted file mode 100644 index 6f80d737..00000000 --- a/packages/core-agent/session/fixtures/providers.ts +++ /dev/null @@ -1,771 +0,0 @@ -import { createSession, restoreSession } from "../session-runtime.ts"; -import type { Scenario } from "./support.ts"; -import { - googleThinkingParallelToolsV2Responses, - markdownAttachmentRestoreForkCompactV2Responses, - responsesThinkingToolsV2Responses, - streamAnthropicMessages3V2Responses, - streamGoogleGenerate3V2Responses, - streamOpenaiChat2V2Responses, - streamOpenaiResponses3V2Responses, - streamTruncatedV2Responses, - thinkingContinuationBranchesSwitchV2Responses, - thinkingParityProviderSwitchV2Responses, -} from "./wire-responses.ts"; - -export const thinkingContinuationBranchesSwitchV2: Scenario = { - name: "thinking-continuation-branches-switch", - version: 2, - group: "providers", - purpose: - "Replay owned thinking through tool loops and fork; remove it after compaction or provider switch.", - source: "packages/core-agent/session/fixtures/providers.ts#thinkingContinuationBranchesSwitchV2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - provider: "anthropic-messages@2", - thinking: "budget", - thinkingBudgetTokens: 1024, - maxOutputTokens: 2048, - }, - completions: thinkingContinuationBranchesSwitchV2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Think and use tools twice' to root."); - const turn1 = root.input("Think and use tools twice"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.action("Fork root into fork."); - const fork = f.track("fork", await root.fork()); - f.action("Submit 'Continue inherited context' to fork."); - const turn4 = fork.input("Continue inherited context"); - const admission4 = await turn4.accepted; - f.record({ op: "input", session: "fork", accepted: admission4 }); - f.check("Input admission is accepted", admission4.kind, "accepted"); - const result4 = await turn4.settled; - f.record({ session: "fork", terminal: result4 }); - f.action("Compact root into compact."); - const compact = f.track("compact", await root.compact([])); - f.action("Submit 'Fresh context' to compact."); - const turn6 = compact.input("Fresh context"); - const admission6 = await turn6.accepted; - f.record({ op: "input", session: "compact", accepted: admission6 }); - f.check("Input admission is accepted", admission6.kind, "accepted"); - const result6 = await turn6.settled; - f.record({ session: "compact", terminal: result6 }); - f.action("Update root policy; expect accepted."); - const receipt7 = await root.updatePolicy({ - provider: "google-generate@1", - thinking: "off", - thinkingBudgetTokens: null, - }); - f.check("Policy update admission", receipt7.kind, "accepted"); - f.record({ op: "policy", session: "root", receipt: receipt7 }); - f.action("Submit 'Switch provider' to root."); - const turn8 = root.input("Switch provider"); - const admission8 = await turn8.accepted; - f.record({ op: "input", session: "root", accepted: admission8 }); - f.check("Input admission is accepted", admission8.kind, "accepted"); - const result8 = await turn8.settled; - f.record({ session: "root", terminal: result8 }); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "fork terminal outcomes", - fork.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "compact terminal outcomes", - compact.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 6); - f.check( - "The fork request includes inherited signed thinking", - JSON.stringify(f.requests[3]).includes("signature-0"), - true, - ); - f.check( - "The compacted request drops old signed thinking", - JSON.stringify(f.requests[4]).includes("signature-0"), - false, - ); - f.check( - "A provider switch drops foreign signed thinking", - JSON.stringify(f.requests[5]).includes("signature-0"), - false, - ); - }, -}; - -export const markdownAttachmentRestoreForkCompactV2: Scenario = { - name: "markdown-attachment-restore-fork-compact", - version: 2, - group: "providers", - purpose: - "Attach a stored Markdown document; reuse its bytes in restored, forked, and replacement contexts.", - source: - "packages/core-agent/session/fixtures/providers.ts#markdownAttachmentRestoreForkCompactV2", - dependencies: { - format: 2, - providerResponses: true, - policy: { provider: "openai-chat@1" }, - completions: markdownAttachmentRestoreForkCompactV2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - const design = await f.persistence.putBlob( - root.snapshot.durable.conversation.sessionId, - new TextEncoder().encode("# DESIGN\nPinned fixture review document."), - { - media: "text/markdown", - name: "DESIGN.md", - }, - new AbortController().signal, - ); - f.action("Submit 'Review this' to root."); - const turn1 = root.input({ text: "Review this", attachments: [design] }); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.action("Fork root into fork."); - const fork = f.track("fork", await root.fork()); - f.action("Submit 'Review again' to fork."); - const turn4 = fork.input("Review again"); - const admission4 = await turn4.accepted; - f.record({ op: "input", session: "fork", accepted: admission4 }); - f.check("Input admission is accepted", admission4.kind, "accepted"); - const result4 = await turn4.settled; - f.record({ session: "fork", terminal: result4 }); - f.action("Compact root into compact."); - const compact = f.track( - "compact", - await root.compact([ - { - role: "user", - text: "", - parts: [ - { - type: "blob", - ref: { - id: "b13b3392f0140b08d909e5d982bafba3bf4a188ae3ecd4db2d4116300126c833", - media: "text/markdown", - bytes: 40, - name: "DESIGN.md", - }, - }, - ], - }, - ]), - ); - f.action("Submit 'Review replacement context' to compact."); - const turn6 = compact.input("Review replacement context"); - const admission6 = await turn6.accepted; - f.record({ op: "input", session: "compact", accepted: admission6 }); - f.check("Input admission is accepted", admission6.kind, "accepted"); - const result6 = await turn6.settled; - f.record({ session: "compact", terminal: result6 }); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "fork terminal outcomes", - fork.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed"], - ); - f.check( - "compact terminal outcomes", - compact.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 3); - }, -}; - -export const googleThinkingParallelToolsV2: Scenario = { - name: "google-thinking-parallel-tools", - version: 2, - group: "providers", - purpose: - "Round-trip Google thought signatures through two rounds of parallel tools and idle restore.", - source: "packages/core-agent/session/fixtures/providers.ts#googleThinkingParallelToolsV2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - maxOutputTokens: 16384, - provider: "google-generate@2", - thinking: "budget", - thinkingBudgetTokens: 1024, - }, - completions: googleThinkingParallelToolsV2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Think and use tools twice' to root."); - const turn1 = root.input("Think and use tools twice"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 3); - f.check( - "The second request echoes the first tool signature", - JSON.stringify(f.requests[1]).includes("call-sig-0-0"), - true, - ); - f.check( - "The third request preserves the second owner signature", - JSON.stringify(f.requests[2]).includes("call-sig-1-0"), - true, - ); - }, -}; - -export const responsesThinkingToolsV2: Scenario = { - name: "responses-thinking-tools", - version: 2, - group: "providers", - purpose: - "Round-trip stateless Responses encrypted reasoning through two tool rounds and idle restore.", - source: "packages/core-agent/session/fixtures/providers.ts#responsesThinkingToolsV2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - provider: "openai-responses@2", - thinking: "high", - }, - completions: responsesThinkingToolsV2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Think and use tools twice' to root."); - const turn1 = root.input("Think and use tools twice"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 3); - f.check( - "The second request echoes encrypted reasoning bytes", - JSON.stringify(f.requests[1]).includes("encrypted-0"), - true, - ); - f.check( - "The third request preserves the second reasoning owner", - JSON.stringify(f.requests[2]).includes("encrypted-1"), - true, - ); - }, -}; - -export const thinkingParityProviderSwitchV2: Scenario = { - name: "thinking-parity-provider-switch", - version: 2, - group: "providers", - purpose: - "Switch between Google and Responses; inject only continuations belonging to the selected provider.", - source: "packages/core-agent/session/fixtures/providers.ts#thinkingParityProviderSwitchV2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - maxOutputTokens: 16384, - provider: "google-generate@2", - thinking: "budget", - thinkingBudgetTokens: 1024, - }, - completions: thinkingParityProviderSwitchV2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'First' to root."); - const turn1 = root.input("First"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Update root policy; expect accepted."); - const receipt2 = await root.updatePolicy({ - provider: "openai-responses@2", - thinking: "high", - thinkingBudgetTokens: null, - }); - f.check("Policy update admission", receipt2.kind, "accepted"); - f.record({ op: "policy", session: "root", receipt: receipt2 }); - f.action("Submit 'Switch to Responses' to root."); - const turn3 = root.input("Switch to Responses"); - const admission3 = await turn3.accepted; - f.record({ op: "input", session: "root", accepted: admission3 }); - f.check("Input admission is accepted", admission3.kind, "accepted"); - const result3 = await turn3.settled; - f.record({ session: "root", terminal: result3 }); - f.action("Update root policy; expect accepted."); - const receipt4 = await root.updatePolicy({ - provider: "google-generate@2", - thinking: "budget", - thinkingBudgetTokens: 1024, - }); - f.check("Policy update admission", receipt4.kind, "accepted"); - f.record({ op: "policy", session: "root", receipt: receipt4 }); - f.action("Submit 'Switch back to Google' to root."); - const turn5 = root.input("Switch back to Google"); - const admission5 = await turn5.accepted; - f.record({ op: "input", session: "root", accepted: admission5 }); - f.check("Input admission is accepted", admission5.kind, "accepted"); - const result5 = await turn5.settled; - f.record({ session: "root", terminal: result5 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore6 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore6, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed", "completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed", "completed", "completed"], - ); - f.check("Provider request count", f.requests.length, 3); - f.check( - "Responses never receives a Google signature", - JSON.stringify(f.requests[1]).includes("google-owned"), - false, - ); - f.check( - "Switching back restores the Google-owned signature", - JSON.stringify(f.requests[2]).includes("google-owned"), - true, - ); - f.check( - "Google never receives Responses encrypted reasoning", - JSON.stringify(f.requests[2]).includes("encrypted-0"), - false, - ); - }, -}; - -export const streamOpenaiChat2V2: Scenario = { - name: "stream-openai-chat-2", - version: 2, - group: "providers", - purpose: - "Assemble an OpenAI Chat stream into tool calls and a final answer before journaling completion.", - source: "packages/core-agent/session/fixtures/providers.ts#streamOpenaiChat2V2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - provider: "openai-chat@2", - stream: true, - thinking: "high", - maxOutputTokens: 2048, - }, - completions: streamOpenaiChat2V2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Stream and use tools' to root."); - const turn1 = root.input("Stream and use tools"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const streamAnthropicMessages3V2: Scenario = { - name: "stream-anthropic-messages-3", - version: 2, - group: "providers", - purpose: - "Assemble an Anthropic stream with thinking and tools, preserving its continuation for the next request.", - source: "packages/core-agent/session/fixtures/providers.ts#streamAnthropicMessages3V2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - provider: "anthropic-messages@3", - stream: true, - thinking: "budget", - thinkingBudgetTokens: 1024, - maxOutputTokens: 2048, - }, - completions: streamAnthropicMessages3V2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Stream and use tools' to root."); - const turn1 = root.input("Stream and use tools"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const streamGoogleGenerate3V2: Scenario = { - name: "stream-google-generate-3", - version: 2, - group: "providers", - purpose: - "Assemble a Google stream with signed tool calls and a final answer before journaling completion.", - source: "packages/core-agent/session/fixtures/providers.ts#streamGoogleGenerate3V2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - provider: "google-generate@3", - stream: true, - thinking: "budget", - thinkingBudgetTokens: 1024, - maxOutputTokens: 2048, - }, - completions: streamGoogleGenerate3V2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Stream and use tools' to root."); - const turn1 = root.input("Stream and use tools"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const streamOpenaiResponses3V2: Scenario = { - name: "stream-openai-responses-3", - version: 2, - group: "providers", - purpose: - "Assemble a Responses stream with encrypted reasoning and tools before journaling completion.", - source: "packages/core-agent/session/fixtures/providers.ts#streamOpenaiResponses3V2", - dependencies: { - format: 2, - providerResponses: true, - policy: { - provider: "openai-responses@3", - stream: true, - thinking: "high", - maxOutputTokens: 2048, - }, - completions: streamOpenaiResponses3V2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Stream and use tools' to root."); - const turn1 = root.input("Stream and use tools"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const streamTruncatedV2: Scenario = { - name: "stream-truncated", - version: 2, - group: "providers", - purpose: - "Reject a stream missing its completion marker; never admit partial text or tools as a completion.", - source: "packages/core-agent/session/fixtures/providers.ts#streamTruncatedV2", - dependencies: { - format: 2, - providerResponses: true, - policy: { provider: "openai-chat@2", stream: true }, - completions: streamTruncatedV2Responses, - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Truncated' to root."); - const turn1 = root.input("Truncated"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["failed"], - ); - f.check("Provider request count", f.requests.length, 1); - f.check( - "No partial assistant completion is committed", - root.snapshot.durable.conversation.log - .flatMap((turn) => turn.messages) - .filter((message) => message.role === "assistant").length, - 0, - ); - }, -}; diff --git a/packages/core-agent/session/fixtures/support.ts b/packages/core-agent/session/fixtures/support.ts deleted file mode 100644 index 38e0c97c..00000000 --- a/packages/core-agent/session/fixtures/support.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import { z } from "zod"; - -import { diagnostic } from "../../logging/index.ts"; -import type { PolicyPatch } from "../../policy/policy.ts"; -import { - anthropicMessagesV2, - anthropicMessagesV3, - googleGenerate, - googleGenerateV2, - googleGenerateV3, - openaiChat, - openaiChatV2, - openaiResponsesV2, - openaiResponsesV3, -} from "../../providers/index.ts"; -import { defineTool, type SessionOptions, type SessionRuntime } from "../session-runtime.ts"; -import { deferred, deterministicIds, lostAcknowledgement, testOptions } from "../test-support.ts"; -import { createMemoryBacking, createMemoryPersistence } from "../testing/memory-persistence.ts"; -import { BodySchema } from "../types.ts"; - -export type Dependencies = { - format?: 2; - providerResponses?: true; - permissions?: readonly unknown[]; - policy?: PolicyPatch; - allowance?: number; - fault?: "reject-input" | "lose-input" | "lose-recovery"; - completions: readonly unknown[]; -}; - -export type Observation = - | { kind: "action"; description: string } - | { - kind: "assertion"; - description: string; - passed: boolean; - actual: unknown; - expected: unknown; - }; - -export type Scenario = { - name: string; - version: 1 | 2; - group: string; - purpose: string; - source: string; - baseline?: "assertions"; - environment?: string; - dependencies: Dependencies; - run: (fixture: Fixture) => Promise; -}; -/** Deterministic environment only: scenarios themselves call the public session API. */ -export function fixtureEnvironment(scenario: Dependencies) { - const observations: Observation[] = []; - const toolRuns: string[] = []; - const backing = createMemoryBacking(); - const base = createMemoryPersistence(backing); - const matches = (records: readonly string[], kind: "input" | "recovery") => - records.some((serialized) => { - const body = BodySchema.parse(JSON.parse(serialized).body); - return kind === "recovery" - ? body.kind === "recovery" - : body.kind === "event" && body.event.type === "user"; - }); - const port = - scenario.fault === "lose-input" || scenario.fault === "lose-recovery" - ? lostAcknowledgement(base, (records) => - matches(records, scenario.fault === "lose-recovery" ? "recovery" : "input"), - ) - : scenario.fault === "reject-input" - ? { - ...base, - append: (request: Parameters[0], signal: AbortSignal) => - matches(request.records, "input") - ? Promise.resolve({ - kind: "rejected" as const, - message: "Scripted rejected append", - }) - : base.append(request, signal), - } - : base; - const requests: unknown[] = []; - const results: unknown[] = []; - const sessions = new Map(); - const deferredWork = new Map< - string, - { promise: Promise; resolve: (value: unknown) => void; value?: unknown } - >(); - let completionIndex = 0; - let httpIndex = 0; - let permissionIndex = 0; - const options = testOptions({ - persistence: port, - id: deterministicIds(), - steps: scenario.allowance ?? 4, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => { - toolRuns.push(text); - if (text.startsWith("error:")) throw new Error(text.slice(6)); - if (!text.startsWith("defer:")) return text; - const work = deferred(); - deferredWork.set(text.slice(6), work); - return work.promise; - }, - }), - ], - ]), - complete(request) { - requests.push({ model: request.model, messages: request.messages, tools: request.tools }); - const outcome = scenario.completions[completionIndex++]; - if (outcome === undefined) throw new Error("Fixture completion script exhausted"); - if (typeof outcome === "object" && outcome !== null && "error" in outcome) - throw new Error(String(outcome.error)); - if (typeof outcome === "object" && outcome !== null && "defer" in outcome) { - const work = deferred(); - deferredWork.set(String(outcome.defer), { - ...work, - value: "value" in outcome ? outcome.value : undefined, - }); - return work.promise; - } - return outcome; - }, - }); - const bind = (configured: SessionOptions): SessionOptions => ({ - persistence: configured.persistence, - configuration: { - agent: configured.configuration.agent, - agents: configured.configuration.agents, - steps: configured.configuration.steps, - policy: scenario.policy, - }, - bindings: { - tools: configured.bindings.tools, - id: configured.bindings.id, - ...(scenario.permissions - ? { - requestPermission: (request: import("../../host/ports.ts").PermissionRequest) => { - results.push({ permission: request }); - const response = scenario.permissions![permissionIndex++]; - if (response === undefined) throw new Error("Fixture permission script exhausted"); - if (typeof response === "object" && response !== null && "defer" in response) { - const work = deferred(); - deferredWork.set(String(response.defer), work); - return work.promise; - } - return response; - }, - } - : {}), - ...(scenario.providerResponses - ? { - providers: new Map( - [ - anthropicMessagesV2, - anthropicMessagesV3, - googleGenerate, - googleGenerateV2, - googleGenerateV3, - openaiChat, - openaiChatV2, - openaiResponsesV2, - openaiResponsesV3, - ].map((profile) => [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - requestId: () => `fixture-http-${++httpIndex}`, - fetch: (async (_url, init) => { - requests.push(JSON.parse(String(init?.body))); - const response = scenario.completions[completionIndex++]; - if (response === undefined) - throw new Error("Fixture provider script exhausted"); - if (typeof response === "object" && response !== null && "sse" in response) - return new Response(z.string().parse(response.sse), { - headers: { "content-type": "text/event-stream" }, - }); - return Response.json(response); - }) as typeof fetch, - }, - }, - ]), - ), - } - : { - complete: (request, signal) => configured.bindings.complete!(request, signal), - }), - }, - }); - - return { - options: bind(options), - // Reopen the same backing store through a fresh persistence handle. No model work on restore. - restoreOptions: () => - bind({ - ...options, - persistence: scenario.fault === "lose-recovery" ? port : createMemoryPersistence(backing), - bindings: { - ...options.bindings, - complete: () => { - throw new Error("Restoration invoked completion"); - }, - }, - }), - persistence: port, - sessions, - requests, - results, - observations, - toolRuns, - get permissionCount() { - return permissionIndex; - }, - track(alias: string, session: SessionRuntime) { - if (sessions.has(alias)) throw new Error(`Duplicate session alias: ${alias}`); - sessions.set(alias, session); - return session; - }, - record(value: unknown) { - results.push(value); - }, - action(description: string) { - observations.push({ kind: "action", description }); - diagnostic("fixture", "info", "scenario.action", { description }); - }, - check(description: string, actual: unknown, expected: unknown) { - const passed = isDeepStrictEqual(actual, expected); - observations.push({ kind: "assertion", description, passed, actual, expected }); - diagnostic("fixture", passed ? "info" : "error", "scenario.assertion", { - description, - passed, - actual, - expected, - }); - if (!passed) - throw new Error( - `${description}\nExpected: ${JSON.stringify(expected)}\nObserved: ${JSON.stringify(actual)}`, - ); - }, - async release(name: string, value?: unknown) { - const work = deferredWork.get(name); - if (!work) throw new Error(`Deferred work not started: ${name}`); - work.resolve(value ?? work.value); - await work.promise; - }, - }; -} - -export type Fixture = ReturnType; diff --git a/packages/core-agent/session/fixtures/tools.ts b/packages/core-agent/session/fixtures/tools.ts deleted file mode 100644 index 62e3550b..00000000 --- a/packages/core-agent/session/fixtures/tools.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { createSession, restoreSession } from "../session-runtime.ts"; -import { until } from "../test-support.ts"; -import type { Scenario } from "./support.ts"; - -export const toolsV1: Scenario = { - name: "tools", - version: 1, - group: "tools", - purpose: - "Let the assistant request two parallel echo tools and use their committed results to answer.", - source: "packages/core-agent/session/fixtures/tools.ts#toolsV1", - dependencies: { - completions: [ - { - kind: "tools", - text: "Checking", - calls: [ - { - id: "c1", - name: "echo", - args: { - text: "one", - }, - }, - { - id: "c2", - name: "echo", - args: { - text: "two", - }, - }, - ], - }, - { - kind: "answer", - text: "Checked", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Run tools' to root."); - const turn1 = root.input("Run tools"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const toolErrorContinueV2: Scenario = { - name: "tool-error-continue", - version: 2, - group: "tools", - purpose: - "Under the tolerant policy, return a failed tool result to the model and let it produce a final answer.", - source: "packages/core-agent/session/fixtures/tools.ts#toolErrorContinueV2", - dependencies: { - format: 2, - policy: { - id: "tolerant@1", - }, - completions: [ - { - kind: "tools", - text: "Work", - calls: [ - { - id: "bad", - name: "echo", - args: { - text: "error:broken", - }, - }, - { - id: "ok", - name: "echo", - args: { - text: "ok", - }, - }, - ], - }, - { - kind: "answer", - text: "Recovered", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const abortToolsOnUserV2: Scenario = { - name: "abort-tools-on-user", - version: 2, - group: "tools", - purpose: - "Under the interruption policy, a new user message cancels active tools and starts a new turn.", - source: "packages/core-agent/session/fixtures/tools.ts#abortToolsOnUserV2", - dependencies: { - format: 2, - policy: { - admission: "abort-tools-on-user", - }, - completions: [ - { - kind: "tools", - text: "Work", - calls: [ - { - id: "fast", - name: "echo", - args: { - text: "fast", - }, - }, - { - id: "slow", - name: "echo", - args: { - text: "defer:slow", - }, - }, - ], - }, - { - kind: "answer", - text: "New turn", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Old' to root."); - const turn1 = root.input("Old"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 partial before the next action."); - await until(() => root.snapshot.durable.partial.length === 1); - f.action("Submit 'New' to root."); - const turn3 = root.input("New"); - const admission3 = await turn3.accepted; - f.record({ op: "input", session: "root", accepted: admission3 }); - f.check("Input admission is accepted", admission3.kind, "accepted"); - const result3 = await turn3.settled; - f.record({ session: "root", terminal: result3 }); - f.action("Release deferred slow work."); - await f.release("slow", "late"); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore5 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore5, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["aborted", "completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["aborted", "completed"], - ); - f.check("Provider request count", f.requests.length, 2); - }, -}; - -export const permissionAllowBatchV2: Scenario = { - name: "permission-allow-batch", - version: 2, - group: "tools", - purpose: "Request approval for both tools before running the approved batch and answering.", - source: "packages/core-agent/session/fixtures/tools.ts#permissionAllowBatchV2", - dependencies: { - format: 2, - policy: { - permissions: "ask", - }, - permissions: [ - { - outcome: { - outcome: "selected", - optionId: "allow-once", - }, - }, - { - outcome: { - outcome: "selected", - optionId: "allow-once", - }, - }, - ], - completions: [ - { - kind: "tools", - text: "Read files", - calls: [ - { - id: "one", - name: "echo", - args: { - text: "one", - }, - }, - { - id: "two", - name: "echo", - args: { - text: "two", - }, - }, - ], - }, - { - kind: "answer", - text: "Read both", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - f.check("Approved tools execute exactly once", f.toolRuns.length, 2); - }, -}; - -export const permissionRejectBatchV2: Scenario = { - name: "permission-reject-batch", - version: 2, - group: "tools", - purpose: - "Reject one tool permission in a batch; the refused call gets a permission-refused result, the approved call runs, and the turn completes.", - source: "packages/core-agent/session/fixtures/tools.ts#permissionRejectBatchV2", - dependencies: { - format: 2, - policy: { - permissions: "ask", - }, - permissions: [ - { - outcome: { - outcome: "selected", - optionId: "allow-once", - }, - }, - { - outcome: { - outcome: "selected", - optionId: "reject-once", - }, - }, - ], - completions: [ - { - kind: "tools", - text: "Read files", - calls: [ - { - id: "one", - name: "echo", - args: { - text: "one", - }, - }, - { - id: "two", - name: "echo", - args: { - text: "two", - }, - }, - ], - }, - { - kind: "answer", - text: "Done", - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore2 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore2, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["completed"], - ); - f.check("Provider request count", f.requests.length, 2); - f.check("Only the approved call executes", f.toolRuns.length, 1); - }, -}; - -export const permissionCancelBatchV2: Scenario = { - name: "permission-cancel-batch", - version: 2, - group: "tools", - purpose: "Cancel the permission interaction and finish without executing tools.", - source: "packages/core-agent/session/fixtures/tools.ts#permissionCancelBatchV2", - dependencies: { - format: 2, - policy: { - permissions: "ask", - }, - permissions: [ - { - outcome: { - outcome: "cancelled", - }, - }, - ], - completions: [ - { - kind: "tools", - text: "Read files", - calls: [ - { - id: "one", - name: "echo", - args: { - text: "one", - }, - }, - { - id: "two", - name: "echo", - args: { - text: "two", - }, - }, - ], - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - const result1 = await turn1.settled; - f.record({ session: "root", terminal: result1 }); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["aborted"], - ); - f.check("Provider request count", f.requests.length, 1); - f.check("No tool executes without batch approval", f.toolRuns.length, 0); - }, -}; - -export const partialCancellationLateResultV1: Scenario = { - name: "partial-cancellation-late-result", - version: 1, - group: "tools", - purpose: - "Cancel a tool batch after one result commits; a late second result must not change the cancelled turn.", - source: "packages/core-agent/session/fixtures/tools.ts#partialCancellationLateResultV1", - dependencies: { - completions: [ - { - kind: "tools", - text: "Working", - calls: [ - { - id: "fast", - name: "echo", - args: { - text: "fast", - }, - }, - { - id: "slow", - name: "echo", - args: { - text: "defer:slow", - }, - }, - ], - }, - ], - }, - async run(f) { - f.action("Create the root session using deterministic test dependencies."); - const root = f.track("root", await createSession(f.options)); - f.action("Submit 'Go' to root."); - const turn1 = root.input("Go"); - const admission1 = await turn1.accepted; - f.record({ op: "input", session: "root", accepted: admission1 }); - f.check("Input admission is accepted", admission1.kind, "accepted"); - f.action("Wait for 1 partial before the next action."); - await until(() => root.snapshot.durable.partial.length === 1); - f.action("Abort the active turn in root."); - await root.fire({ type: "abort" }); - f.record({ session: "root", terminal: await turn1.settled }); - f.action("Release deferred slow work."); - await f.release("slow", "late"); - f.action("Restore root as restored from its journal, without model work."); - const requestsBeforeRestore5 = f.requests.length; - const restored = f.track( - "restored", - await restoreSession(f.restoreOptions(), root.snapshot.durable.conversation.sessionId), - ); - f.check( - "Provider request count is unchanged by restore", - f.requests.length, - requestsBeforeRestore5, - ); - f.check( - "Restore keeps the session identity", - restored.snapshot.durable.conversation.sessionId, - root.snapshot.durable.conversation.sessionId, - ); - f.check( - "root terminal outcomes", - root.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["aborted"], - ); - f.check( - "restored terminal outcomes", - restored.snapshot.durable.conversation.log.map((turn) => turn.outcome.kind), - ["aborted"], - ); - f.check("Provider request count", f.requests.length, 1); - f.check( - "A late tool result never enters committed history", - JSON.stringify(root.snapshot.durable.conversation.log).includes("late"), - false, - ); - }, -}; diff --git a/packages/core-agent/session/fixtures/usage.ts b/packages/core-agent/session/fixtures/usage.ts deleted file mode 100644 index 8e527ed0..00000000 --- a/packages/core-agent/session/fixtures/usage.ts +++ /dev/null @@ -1,184 +0,0 @@ -// Start here: ordinary usage through the package's public API. Dependencies are local doubles. -import { z } from "zod"; - -import { createSession, defineTool, restoreSession, type SessionOptions } from "../../index.ts"; -import { deterministicIds } from "../test-support.ts"; -import type { Scenario } from "./support.ts"; - -export const conversationUsage: Scenario = { - name: "usage-conversation-and-restore", - version: 2, - group: "getting-started", - baseline: "assertions", - purpose: - "Configure a session, distinguish durable admission from the final answer, and reopen the same saved conversation.", - source: "packages/core-agent/session/fixtures/usage.ts#conversationUsage", - environment: - "The assistant is named reviewer. A local completion double answers once. Persistence is process-local memory, shared across close and restore; this demonstrates the API, not disk crash durability.", - dependencies: { completions: [] }, - async run(f) { - let modelCalls = 0; - const options: SessionOptions = { - persistence: f.persistence, - configuration: { - agent: "reviewer", - agents: new Map([ - [ - "reviewer", - { - model: "example-model", - systemPrompt: "Answer the user's question.", - tools: [], - successors: [], - }, - ], - ]), - steps: 4, - }, - bindings: { - id: deterministicIds(), - tools: new Map(), - complete: (request) => { - modelCalls++; - f.requests.push(request); - return { kind: "answer", text: "The session retains committed conversation history." }; - }, - }, - }; - f.action("Create a reviewer session with explicit configuration and environment bindings."); - const session = f.track("original", await createSession(options)); - const savedId = session.snapshot.durable.conversation.sessionId; - - f.action("Ask a question, then wait separately for admission and settlement."); - const turn = session.input("What does a session retain?"); - const admission = await turn.accepted; - f.record({ admission }); - f.check("The user message is durably admitted", admission.kind, "accepted"); - const answer = await turn.settled; - f.record({ answer }); - f.check("The turn reaches a terminal result", answer.kind, "terminal"); - if (answer.kind !== "terminal") throw new Error("Expected a terminal answer"); - f.check("The assistant completes successfully", answer.record.outcome.kind, "completed"); - f.check( - "The answer is in committed history", - answer.record.messages.at(-1)?.text, - "The session retains committed conversation history.", - ); - const savedConversation = session.snapshot.durable.conversation.log; - - f.action("Close the runtime and reopen the saved session ID using the same persistence."); - await session.close(); - const reopened = f.track("reopened", await restoreSession(options, savedId)); - f.check( - "Reopening preserves the committed conversation", - reopened.snapshot.durable.conversation.log, - savedConversation, - ); - f.check("Reopening does not call the model again", modelCalls, 1); - }, -}; - -// The two scenarios differ only in the host's permission response; the public API is identical. -function toolUsage(allow: boolean): Scenario { - return { - name: allow ? "usage-approve-tool" : "usage-deny-tool", - version: 2, - group: "getting-started", - baseline: "assertions", - purpose: allow - ? "Define a typed tool, approve its invocation through the host permission port, and let the assistant use its result." - : "Deny a requested tool through the host permission port, verify its effect never runs, and confirm the turn continues with a permission-refused result.", - source: "packages/core-agent/session/fixtures/usage.ts#toolUsage", - environment: - "The assistant is named reviewer. The read_note tool reads a local in-memory note, not a real file. Provider and permission responses are deterministic doubles. The real runtime owns the tool loop and admission ordering.", - dependencies: { completions: [] }, - async run(f) { - const notes: Record = { - design: "Keep decisions pure; persist before releasing effects.", - }; - let reads = 0; - let modelCalls = 0; - let permissions = 0; - const options: SessionOptions = { - persistence: f.persistence, - configuration: { - agent: "reviewer", - agents: new Map([ - ["reviewer", { model: "example-model", tools: ["read_note"], successors: [] }], - ]), - steps: 4, - policy: { permissions: "ask" }, - }, - bindings: { - id: deterministicIds(), - tools: new Map([ - [ - "read_note", - defineTool({ - input: z.object({ name: z.string() }), - kind: "read", - run: ({ name }) => { - reads++; - return { body: notes[name] ?? "Note not found" }; - }, - }), - ], - ]), - requestPermission: (request) => { - permissions++; - f.record({ permission: request }); - f.check("Permission is requested before the tool runs", reads, 0); - return { - outcome: { outcome: "selected", optionId: allow ? "allow-once" : "reject-once" }, - }; - }, - complete: (request) => { - f.requests.push(request); - modelCalls++; - if (modelCalls === 1) - return { - kind: "tools", - text: "I will read the design note.", - calls: [{ id: "read-design", name: "read_note", args: { name: "design" } }], - }; - f.check( - "The follow-up model request includes the tool result", - JSON.stringify(request.messages).includes( - allow ? notes.design! : "The user refused permission for this call", - ), - true, - ); - return { - kind: "answer", - text: allow - ? "The design keeps decisions pure and persists before releasing effects." - : "I could not read the design note; permission was refused.", - }; - }, - }, - }; - f.action( - `Create a session whose host will ${allow ? "approve" : "deny"} the requested note read.`, - ); - const session = f.track("review", await createSession(options)); - const turn = session.input("Summarize the design note."); - f.check("The prompt is admitted", (await turn.accepted).kind, "accepted"); - const result = await turn.settled; - f.record({ result }); - f.check("The turn settles", result.kind, "terminal"); - if (result.kind !== "terminal") throw new Error("Expected a terminal tool outcome"); - f.check("The host receives one permission request", permissions, 1); - f.check( - allow ? "The approved effect runs once" : "The denied effect never runs", - reads, - allow ? 1 : 0, - ); - f.check("The model is asked exactly twice", modelCalls, 2); - f.check("The turn always completes, refusal or not", result.record.outcome.kind, "completed"); - }, - }; -} - -export const approveToolUsage = toolUsage(true); - -export const denyToolUsage = toolUsage(false); diff --git a/packages/core-agent/session/fixtures/wire-responses.ts b/packages/core-agent/session/fixtures/wire-responses.ts deleted file mode 100644 index 84319e91..00000000 --- a/packages/core-agent/session/fixtures/wire-responses.ts +++ /dev/null @@ -1,386 +0,0 @@ -// Canned provider wire responses, not session operations. Real profiles and transport decode these. -export const thinkingContinuationBranchesSwitchV2Responses = [ - { - role: "assistant", - stop_reason: "tool_use", - content: [ - { - type: "thinking", - thinking: "private", - signature: "signature-0", - }, - { - type: "tool_use", - id: "c0", - name: "echo", - input: { - text: "result-0", - }, - }, - ], - }, - { - role: "assistant", - stop_reason: "tool_use", - content: [ - { - type: "thinking", - thinking: "private", - signature: "signature-1", - }, - { - type: "tool_use", - id: "c1", - name: "echo", - input: { - text: "result-1", - }, - }, - ], - }, - { - role: "assistant", - stop_reason: "end_turn", - content: [ - { - type: "thinking", - thinking: "private", - signature: "signature-2", - }, - { - type: "text", - text: "Done", - }, - ], - }, - { - role: "assistant", - stop_reason: "end_turn", - content: [ - { - type: "thinking", - thinking: "private", - signature: "signature-3", - }, - { - type: "text", - text: "Done", - }, - ], - }, - { - role: "assistant", - stop_reason: "end_turn", - content: [ - { - type: "thinking", - thinking: "private", - signature: "signature-4", - }, - { - type: "text", - text: "Done", - }, - ], - }, - { - candidates: [ - { - finishReason: "STOP", - content: { - role: "model", - parts: [ - { - text: "Google answer", - }, - ], - }, - }, - ], - }, -]; - -export const markdownAttachmentRestoreForkCompactV2Responses = [ - { - choices: [ - { - message: { - content: "Reviewed", - }, - }, - ], - }, - { - choices: [ - { - message: { - content: "Fork review", - }, - }, - ], - }, - { - choices: [ - { - message: { - content: "Compact review", - }, - }, - ], - }, -]; - -export const googleThinkingParallelToolsV2Responses = [ - { - candidates: [ - { - finishReason: "STOP", - content: { - role: "model", - parts: [ - { - text: "private", - thought: true, - }, - { - text: "work-0", - thoughtSignature: "text-sig-0", - }, - { - functionCall: { - id: "c0-0", - name: "echo", - args: { - text: "result-0-0", - }, - }, - thoughtSignature: "call-sig-0-0", - }, - { - functionCall: { - id: "c0-1", - name: "echo", - args: { - text: "result-0-1", - }, - }, - thoughtSignature: "call-sig-0-1", - }, - ], - }, - }, - ], - }, - { - candidates: [ - { - finishReason: "STOP", - content: { - role: "model", - parts: [ - { - text: "private", - thought: true, - }, - { - text: "work-1", - thoughtSignature: "text-sig-1", - }, - { - functionCall: { - id: "c1-0", - name: "echo", - args: { - text: "result-1-0", - }, - }, - thoughtSignature: "call-sig-1-0", - }, - { - functionCall: { - id: "c1-1", - name: "echo", - args: { - text: "result-1-1", - }, - }, - thoughtSignature: "call-sig-1-1", - }, - ], - }, - }, - ], - }, - { - candidates: [ - { - finishReason: "STOP", - content: { - role: "model", - parts: [ - { - text: "Done", - thoughtSignature: "answer-sig", - }, - ], - }, - }, - ], - }, -]; - -export const responsesThinkingToolsV2Responses = [ - { - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_0", - summary: [], - encrypted_content: "encrypted-0", - }, - { - type: "function_call", - call_id: "c0", - name: "echo", - arguments: '{"text":"result-0"}', - }, - ], - }, - { - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_1", - summary: [], - encrypted_content: "encrypted-1", - }, - { - type: "function_call", - call_id: "c1", - name: "echo", - arguments: '{"text":"result-1"}', - }, - ], - }, - { - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_2", - summary: [], - encrypted_content: "encrypted-2", - }, - { - type: "message", - role: "assistant", - content: [ - { - type: "output_text", - text: "Done", - }, - ], - }, - ], - }, -]; - -export const thinkingParityProviderSwitchV2Responses = [ - { - candidates: [ - { - finishReason: "STOP", - content: { - role: "model", - parts: [ - { - text: "Google answer", - thoughtSignature: "google-owned", - }, - ], - }, - }, - ], - }, - { - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_0", - summary: [], - encrypted_content: "encrypted-0", - }, - { - type: "message", - role: "assistant", - content: [ - { - type: "output_text", - text: "Responses answer", - }, - ], - }, - ], - }, - { - candidates: [ - { - finishReason: "STOP", - content: { - role: "model", - parts: [ - { - text: "Google again", - thoughtSignature: "google-second", - }, - ], - }, - }, - ], - }, -]; - -export const streamOpenaiChat2V2Responses = [ - { - sse: 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"Hello "},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{"content":"🌍"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\\"text\\":"}}]},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"Hello 🌍\\"}"}}]},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: {"choices":[],"usage":{"completion_tokens":4}}\n\ndata: [DONE]\n\n', - }, - { - sse: 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"Hello "},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{"content":"🌍"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: {"choices":[],"usage":{"completion_tokens":4}}\n\ndata: [DONE]\n\n', - }, -]; - -export const streamAnthropicMessages3V2Responses = [ - { - sse: 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg","role":"assistant","content":[],"stop_reason":null,"usage":{"input_tokens":1}}}\n\nevent: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Considering"}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"signature"}}\n\nevent: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\nevent: content_block_start\ndata: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello "}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"🌍"}}\n\nevent: content_block_stop\ndata: {"type":"content_block_stop","index":1}\n\nevent: content_block_start\ndata: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"c1","name":"echo","input":{}}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\\"text\\":"}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\"Hello 🌍\\"}"}}\n\nevent: content_block_stop\ndata: {"type":"content_block_stop","index":2}\n\nevent: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":4}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', - }, - { - sse: 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg","role":"assistant","content":[],"stop_reason":null,"usage":{"input_tokens":1}}}\n\nevent: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Considering"}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"signature"}}\n\nevent: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\nevent: content_block_start\ndata: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello "}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"🌍"}}\n\nevent: content_block_stop\ndata: {"type":"content_block_stop","index":1}\n\nevent: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', - }, -]; - -export const streamGoogleGenerate3V2Responses = [ - { - sse: 'data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Considering","thought":true}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Hello "}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"🌍"}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"id":"c1","name":"echo","args":{"text":"Hello 🌍"}}}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"thoughtSignature":"signature"}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"candidatesTokenCount":4}}\n\n', - }, - { - sse: 'data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Considering","thought":true}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Hello "}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"🌍","thoughtSignature":"signature"}]}}],"usageMetadata":{"candidatesTokenCount":4}}\n\ndata: {"candidates":[{"index":0,"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"candidatesTokenCount":4}}\n\n', - }, -]; - -export const streamOpenaiResponses3V2Responses = [ - { - sse: 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp","status":"in_progress","output":[]}}\n\nevent: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","delta":"Considering"}\n\nevent: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello "}\n\nevent: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"🌍"}\n\nevent: response.function_call_arguments.delta\ndata: {"type":"response.function_call_arguments.delta","delta":"{\\"text\\":"}\n\nevent: response.function_call_arguments.done\ndata: {"type":"response.function_call_arguments.done","arguments":"{\\"text\\":\\"Hello 🌍\\"}"}\n\nevent: response.completed\ndata: {"type":"response.completed","response":{"id":"resp","status":"completed","usage":{"output_tokens":4},"output":[{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"signature"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello 🌍"}]},{"type":"function_call","call_id":"c1","name":"echo","arguments":"{\\"text\\":\\"Hello 🌍\\"}"}]}}\n\n', - }, - { - sse: 'event: response.created\ndata: {"type":"response.created","response":{"id":"resp","status":"in_progress","output":[]}}\n\nevent: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","delta":"Considering"}\n\nevent: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello "}\n\nevent: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"🌍"}\n\nevent: response.completed\ndata: {"type":"response.completed","response":{"id":"resp","status":"completed","usage":{"output_tokens":4},"output":[{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"signature"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello 🌍"}]}]}}\n\n', - }, -]; - -export const streamTruncatedV2Responses = [ - { - sse: 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"Hello "},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{"content":"🌍"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{\\"text\\":"}}]},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"Hello 🌍\\"}"}}]},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: {"choices":[],"usage":{"completion_tokens":4}}\n\n', - }, -]; diff --git a/packages/core-agent/session/index.ts b/packages/core-agent/session/index.ts deleted file mode 100644 index e471d68a..00000000 --- a/packages/core-agent/session/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Public entry point of the durable session: {@link createSession} and {@link restoreSession}, - * the {@link SessionRuntime} they return, the persistence port and the journal types. - * @module - */ -export * from "./session-runtime.ts"; -export * from "./persistence.ts"; -export * from "./types.ts"; -export * from "./session-log.ts"; -export * from "./session-prompt.ts"; -export * from "./views.ts"; - -export type { CommandReceipt, SessionState } from "./session-fsm.ts"; - -export * from "./events.ts"; - -export type { Policy, PolicyPatch, PolicyResolvers } from "../policy/policy.ts"; - -export type { CompletionPort, CompletionPortResponse } from "../host/ports.ts"; - -/** The prepared request a {@link CompletionPort} receives for one step. */ -export type { PreparedModel as CompletionPortRequest } from "../agent/agent.ts"; - -export type { Completion, Failure } from "../agent/types.ts"; - -export { PreparedModelSchema as CompletionPortRequestSchema } from "../agent/agent.ts"; - -export type { ResolvedModel } from "../providers/transport.ts"; - -export { CompletionUsageSchema, type CompletionUsage } from "../providers/usage.ts"; diff --git a/packages/core-agent/session/journal-integrity.test.ts b/packages/core-agent/session/journal-integrity.test.ts deleted file mode 100644 index d9843b2b..00000000 --- a/packages/core-agent/session/journal-integrity.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { getLogger } from "@logtape/logtape"; -import { expect, spyOn, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { AppendIdSchema, RevisionSchema, type CommittedBatch } from "./persistence.ts"; -import { - decodeRecord, - JournalIntegrityError, - replay, - stage, - stageCreation, - type JournalIntegrityRule, - type JournalLocation, -} from "./session-log.ts"; -import { createSession, restoreSession, type SessionOptions } from "./session-runtime.ts"; -import { scriptedCompletion, testOptions } from "./test-support.ts"; - -type Edit = readonly [path: readonly (string | number)[], value: unknown]; - -function scripted() { - return testOptions({ - complete: scriptedCompletion([ - { kind: "tools", text: "work", calls: [{ id: "c", name: "echo", args: { text: "result" } }] }, - { kind: "answer", text: "done" }, - ]), - }); -} - -async function committed(options: SessionOptions = scripted()) { - const session = await createSession(options); - await session.updatePolicy({ steps: 5 }); - await session.input("Go").settled; - const durable = session.snapshot.durable; - await session.close(); - const loaded = await options.persistence.load( - durable.conversation.sessionId, - new AbortController().signal, - ); - if (loaded.kind !== "loaded") throw new Error("Expected a committed journal"); - return { options, durable, batches: loaded.batches }; -} - -/** Index of the nth batch holding a record of this kind; event records go by their event type. */ -function position(batches: readonly CommittedBatch[], kind: string, nth = 0): number { - const found = batches.flatMap((batch, index) => - batch.records.some((raw) => { - const body = decodeRecord(raw).body; - const recorded = - body.kind !== "event" - ? body.kind - : body.event.type === "child" - ? body.event.event.type - : body.event.type; - return recorded === kind; - }) - ? [index] - : [], - )[nth]; - if (found === undefined) throw new Error(`No ${kind} record ${nth}`); - return found; -} - -/** Rewrite one record's bytes, as a damaged store or a writer with other rules would store them. */ -function rewrite( - batches: readonly CommittedBatch[], - at: number, - edits: readonly Edit[], - index = 0, -): CommittedBatch[] { - return batches.map((batch, current) => - current !== at - ? batch - : { - ...batch, - records: batch.records.map((raw, entry) => { - if (entry !== index) return raw; - const record: unknown = JSON.parse(raw); - for (const [path, value] of edits) { - let node = record as Record; - for (const key of path.slice(0, -1)) - node = node[key] as Record; - node[path.at(-1)!] = value; - } - return JSON.stringify(record); - }), - }, - ); -} - -/** Index of the terminal record within its batch: the last record of the append that ended the turn. */ -function terminalIndex(batches: readonly CommittedBatch[]): number { - return batches[position(batches, "terminal")]!.records.length - 1; -} - -function located(batches: readonly CommittedBatch[], at: number, index = 0): JournalLocation { - const record = decodeRecord(batches[at]!.records[index]!); - return { revision: record.revision, appendId: record.appendId, entryId: record.entryId }; -} - -/** Options whose store loads these batches, as a store holding exactly those bytes would. */ -function serving(options: SessionOptions, batches: readonly CommittedBatch[]): SessionOptions { - const port = options.persistence; - return { - ...options, - persistence: { - lifetime: port.lifetime, - putBlob: port.putBlob.bind(port), - getBlob: port.getBlob.bind(port), - append: port.append.bind(port), - getConfig: port.getConfig.bind(port), - putConfig: port.putConfig.bind(port), - async load(sessionId, signal) { - const loaded = await port.load(sessionId, signal); - return loaded.kind === "loaded" ? { ...loaded, batches } : loaded; - }, - }, - }; -} - -function violation(load: () => unknown): JournalIntegrityError { - try { - load(); - } catch (error) { - if (error instanceof JournalIntegrityError) return error; - throw error; - } - throw new Error("The journal loaded"); -} - -test("a configuration-applied record loads its stored policy, whatever today's rules derive", async () => { - const { durable, batches } = await committed(); - const at = position(batches, "policy"); - const changed = rewrite(batches, at, [ - [["body", "policy", "bargeIn"], false], - [["body", "policy", "toolFailure"], "return-error-and-continue"], - ]); - const stored = decodeRecord(changed[at]!.records[0]!).body; - if (stored.kind !== "policy") throw new Error("Expected the policy record"); - const state = replay(changed); - expect(state.policy).toEqual(stored.policy); - expect(state.policy).not.toEqual(durable.policy); -}); - -test("a terminal record's agent and outcome load as written; the turn's messages come from the fold", async () => { - const { options, durable, batches } = await committed(); - const at = position(batches, "terminal"); - const outcome = { kind: "aborted", reason: { message: "as recorded by an older build" } }; - const changed = rewrite(batches, at, [[["body", "outcome"], outcome]], terminalIndex(batches)); - const state = replay(changed); - expect(state.conversation.log.at(-1)).toEqual({ - ...durable.conversation.log.at(-1)!, - outcome, - }); - const restored = await restoreSession(serving(options, changed), durable.conversation.sessionId); - try { - expect(restored.snapshot.durable.conversation.log.at(-1)?.outcome).toEqual(outcome); - } finally { - await restored.close(); - } -}); - -test("an effect record loads whatever the turn state, and its usage becomes the latest", async () => { - const { durable, batches } = await committed(); - const last = batches.at(-1)!; - const revision = RevisionSchema.parse(last.revision + 1); - const appendId = AppendIdSchema.parse("late-effect"); - const usage = { status: "reported", inputTokens: 7, native: { input_tokens: 7 } }; - const turnId = `${durable.conversation.sessionId}/turn/1`; - const operationId = `${turnId}/1`; - const state = replay([ - ...batches, - { - sessionId: durable.conversation.sessionId, - expectedRevision: last.revision, - appendId, - revision, - records: [ - JSON.stringify({ - version: 2, - sessionId: durable.conversation.sessionId, - revision, - appendId, - entryId: `${appendId}/0`, - body: { kind: "effect", turnId, operationId, effect: "usage", usage }, - }), - ], - }, - ]); - expect(state.conversation).toEqual(durable.conversation); - expect(state.lastCompletionUsage).toEqual({ turnId, operationId, usage }); -}); - -const violations: readonly (readonly [ - name: string, - corrupt: (batches: readonly CommittedBatch[]) => readonly CommittedBatch[], - rule: JournalIntegrityRule, - offending: (batches: readonly CommittedBatch[]) => JournalLocation, -])[] = [ - [ - "a revision gap", - (batches) => batches.filter((_, index) => index !== position(batches, "model_settled")), - "batch_continuity", - (batches) => located(batches, position(batches, "model_settled") + 1), - ], - [ - "a record revision out of sequence", - (batches) => rewrite(batches, position(batches, "user"), [[["revision"], 7]]), - "revision_sequence", - (batches) => ({ ...located(batches, position(batches, "user")), revision: 7 }), - ], - [ - "a duplicate appendId", - (batches) => [...batches, batches.at(-1)!], - "append_unique", - (batches) => located(batches, batches.length - 1), - ], - [ - "a record of another session", - (batches) => - rewrite(batches, position(batches, "user"), [ - [["sessionId"], "00000000-0000-4000-8000-999999999999"], - ]), - "session_identity", - (batches) => located(batches, position(batches, "user")), - ], - [ - "a record of another append", - (batches) => rewrite(batches, position(batches, "user"), [[["appendId"], "different"]]), - "append_identity", - (batches) => ({ ...located(batches, position(batches, "user")), appendId: "different" }), - ], - [ - "an entry ID out of format", - (batches) => rewrite(batches, position(batches, "user"), [[["entryId"], "entry"]]), - "entry_format", - (batches) => ({ ...located(batches, position(batches, "user")), entryId: "entry" }), - ], - [ - "an undecodable record", - (batches) => - batches.map((batch, index) => - index === position(batches, "user") ? { ...batch, records: ["{"] } : batch, - ), - "record_decode", - (batches) => located(batches, position(batches, "user")), - ], - [ - "an unsupported record version", - (batches) => rewrite(batches, position(batches, "user"), [[["version"], 3]]), - "record_decode", - (batches) => located(batches, position(batches, "user")), - ], - [ - "a missing creation record", - (batches) => rewrite(batches, 0, [[["body"], { kind: "system", inputs: [], version: 1 }]]), - "creation_first", - (batches) => located(batches, 0), - ], - [ - "a missing terminal record", - (batches) => - batches.map((batch, index) => - index === position(batches, "terminal") - ? { - ...batch, - records: batch.records.slice(0, -1), - revision: RevisionSchema.parse(batch.revision - 1), - } - : batch, - ), - "terminal_same_batch", - (batches) => located(batches, position(batches, "terminal")), - ], - [ - "a terminal record of another turn", - (batches) => - rewrite( - batches, - position(batches, "terminal"), - [[["body", "turnId"], "other-turn"]], - terminalIndex(batches), - ), - "terminal_required", - (batches) => located(batches, position(batches, "terminal"), terminalIndex(batches)), - ], - [ - "a terminal record without a turn transition", - (batches) => - rewrite(batches, position(batches, "policy"), [ - [ - ["body"], - decodeRecord(batches[position(batches, "terminal")]!.records[terminalIndex(batches)]!) - .body, - ], - ]), - "terminal_unexpected", - (batches) => located(batches, position(batches, "policy")), - ], - [ - "a tool result for a call the batch lacks", - (batches) => rewrite(batches, position(batches, "tool"), [[["body", "callId"], "unknown"]]), - "record_applicable", - (batches) => located(batches, position(batches, "tool")), - ], -]; - -for (const [name, corrupt, rule, offending] of violations) - test(`load rejects ${name}, naming the rule and the record`, async () => { - const { batches } = await committed(); - expect(violation(() => replay(corrupt(batches)))).toMatchObject({ - rule, - ...offending(batches), - }); - }); - -test("an empty journal has no creation record", () => { - expect(violation(() => replay([]))).toMatchObject({ rule: "creation_first" }); -}); - -test("staging still rejects records that break commit-time rules", async () => { - const echo = testOptions().bindings.tools!.get("echo")!; - const { batches } = await committed( - testOptions({ - agents: new Map([ - ["a", { model: "m", systemPrompt: "Agent A", tools: ["echo", "other"] }], - ["b", { model: "m", tools: ["echo"] }], - ]), - tools: new Map([ - ["echo", echo], - ["other", echo], - ]), - }), - ); - const staged = (at: number, edits: readonly Edit[]) => { - const body = decodeRecord(rewrite(batches, at, edits)[at]!.records[0]!).body; - if (body.kind !== "event") throw new Error("Expected an event record"); - return () => - stage(replay(batches.slice(0, at)), body, AppendIdSchema.parse(batches[at]!.appendId)); - }; - const settled = position(batches, "model_settled"); - expect(staged(settled, [])().records).toEqual([...batches[settled]!.records]); - expect(staged(settled, [[["body", "systemVersion"], 1]])).toThrow("Turn system version mismatch"); - expect( - staged(settled, [ - [ - ["body", "event", "event", "result", "value"], - { kind: "tools", text: "", calls: [{ id: "call", name: "unknown", args: {} }] }, - ], - ]), - ).toThrow("Unpermitted tool"); - const created = decodeRecord(batches[0]!.records[0]!).body; - if (created.kind !== "created") throw new Error("Expected the creation record"); - const tools = Object.fromEntries( - Object.entries(created.seed.policy.tools).filter(([agent]) => agent !== "b"), - ); - expect(() => - stageCreation( - { ...created.seed, policy: { ...created.seed.policy, tools } }, - AppendIdSchema.parse(batches[0]!.appendId), - ), - ).toThrow("capabilities"); -}); - -test("a restore that fails integrity logs the rule and the offending record", async () => { - const records: { event: string; fields: Record }[] = []; - const logger = getLogger(["labkit", "session"]); - const emit = logger.emit.bind(logger); - const spy = spyOn(logger, "emit").mockImplementation((record) => { - records.push({ event: String(record.rawMessage), fields: record.properties }); - emit(record); - }); - try { - const { options, durable, batches } = await committed(); - const sessionId = durable.conversation.sessionId; - const dropped = position(batches, "model_settled"); - const gap = batches.filter((_, index) => index !== dropped); - const offending = located(batches, dropped + 1); - const error = await restoreSession(serving(options, gap), sessionId).catch( - (failure: unknown) => failure, - ); - expect(error).toBeInstanceOf(JournalIntegrityError); - expect(error).toMatchObject({ rule: "batch_continuity", ...offending }); - expect( - records.find((record) => record.event === "session.restore_failed")?.fields, - ).toMatchObject({ - sessionId, - stage: "replay_journal", - rule: "batch_continuity", - ...offending, - error: { name: "JournalIntegrityError" }, - }); - } finally { - spy.mockRestore(); - } -}); - -const newerBuild: readonly (readonly [name: string, edits: readonly Edit[], named: string])[] = [ - ["an unknown record kind", [[["body"], { kind: "future_kind" }]], 'kind "future_kind"'], - ["a newer record version", [[["version"], 3]], "version 3"], -]; - -for (const [name, edits, named] of newerBuild) - test(`load reports ${name} as written by a newer Labkit build`, async () => { - const records: { event: string; fields: Record }[] = []; - const logger = getLogger(["labkit", "session"]); - const emit = logger.emit.bind(logger); - const spy = spyOn(logger, "emit").mockImplementation((record) => { - records.push({ event: String(record.rawMessage), fields: record.properties }); - emit(record); - }); - try { - const { options, durable, batches } = await committed(); - const sessionId = durable.conversation.sessionId; - const at = position(batches, "user"); - const offending = located(batches, at); - const error = await restoreSession( - serving(options, rewrite(batches, at, edits)), - sessionId, - ).catch((failure: unknown) => failure); - expect(error).toBeInstanceOf(JournalIntegrityError); - expect(error).toMatchObject({ rule: "record_decode", ...offending }); - const message = (error as Error).message; - expect(message).toContain(named); - expect(message).toContain("written by a newer Labkit build"); - expect(message).toContain("restart the launcher on current code"); - expect((error as Error).cause).toBeInstanceOf(z.ZodError); - expect( - records.find((record) => record.event === "session.restore_failed")?.fields, - ).toMatchObject({ - sessionId, - stage: "replay_journal", - rule: "record_decode", - ...offending, - error: { name: "JournalIntegrityError", message }, - }); - } finally { - spy.mockRestore(); - } - }); - -test("load reports a record that is not JSON as unreadable, not as a newer build", async () => { - const { batches } = await committed(); - const at = position(batches, "user"); - const error = violation(() => - replay(batches.map((batch, index) => (index === at ? { ...batch, records: ["{"] } : batch))), - ); - expect(error).toMatchObject({ rule: "record_decode", ...located(batches, at) }); - expect(error.message).toContain("not valid JSON"); - expect(error.message).not.toContain("newer Labkit build"); -}); - -test("load reports a record in the pre-break format as unreadable, with no migration", async () => { - const { batches } = await committed(); - const at = position(batches, "user"); - const error = violation(() => replay(rewrite(batches, at, [[["version"], 1]]))); - expect(error).toMatchObject({ rule: "record_decode", ...located(batches, at) }); - expect(error.message).toContain("older journal format"); - expect(error.message).toContain("start a new session"); - expect(error.message).not.toContain("newer Labkit build"); -}); diff --git a/packages/core-agent/session/journal/codec.ts b/packages/core-agent/session/journal/codec.ts deleted file mode 100644 index 6973c3e6..00000000 --- a/packages/core-agent/session/journal/codec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ConversationEvent } from "../../agent/agent-conversation.ts"; -import { freeze } from "../../fsm/fsm.ts"; -import type { CompletionUsage } from "../../providers/usage.ts"; -import { - BodySchema, - JournalRecordSchema, - WireEventSchema, - type JournalRecord, - type WireEvent, -} from "../types.ts"; - -/** - * Converts a conversation event into its journaled form ({@link WireEvent}). A settled step's - * usage is stripped here; {@link completionUsage} reads it separately for the caller to stage as a - * sibling `effect` record. A settled batch keeps only how it settled: its results are already - * committed as `tool` records, and the fold assembles them from those. - */ -export function wireEvent(event: ConversationEvent): WireEvent { - if (event.type === "dispatch_failed") { - if (event.command.type !== "turn") throw new Error("Cannot journal a failed branch callback"); - return WireEventSchema.parse({ - type: "child", - turnId: event.command.turnId, - event: { type: "failed", child: event.command.command.child, error: event.error }, - }); - } - if (event.type === "child" && event.event.type === "model_settled") { - const { usage: _usage, permissionRequired: _derived, ...settled } = event.event; - return WireEventSchema.parse({ ...event, event: settled }); - } - if (event.type === "child" && event.event.type === "batch_settled") { - const { results: _results, ...outcome } = event.event.outcome; - return WireEventSchema.parse({ ...event, event: { ...event.event, outcome } }); - } - return WireEventSchema.parse(event); -} - -/** The usage a settled step reported, when it admitted a completion; undefined otherwise. */ -export function completionUsage(event: ConversationEvent): CompletionUsage | undefined { - return event.type === "child" && event.event.type === "model_settled" - ? event.event.usage - : undefined; -} - -/** - * Serializes a record as a journal append stores it. - * - * @throws ZodError when the record is not a current-format journal record. - */ -export function encodeRecord(record: JournalRecord): string { - return JSON.stringify(JournalRecordSchema.parse(record)); -} - -/** - * Parses one stored record and deep-freezes it. Checks only the record format; journal integrity is - * checked by {@link replay}, which reports a failure here as `record_decode`. - * - * @throws SyntaxError for invalid JSON; ZodError for anything else that is not a current-format - * record, including a newer `version` or an unknown body `kind`. - */ -export function decodeRecord(serialized: string): JournalRecord { - return freeze(JournalRecordSchema.parse(JSON.parse(serialized))); -} - -export const recordKinds: ReadonlySet = new Set( - BodySchema.options.map((option) => option.shape.kind.value), -); - -export const newerBuild = - "the journal was probably written by a newer Labkit build, so restart the launcher on current code"; - -/** The one record format this build reads and writes. */ -const currentVersion = JournalRecordSchema.unwrap().shape.version.value; - -/** Why stored bytes do not decode, read from the raw JSON without validating it. */ -export function decodeFailure(serialized: string): string { - let raw: unknown; - try { - raw = JSON.parse(serialized); - } catch { - return "Record is not valid JSON"; - } - const field = (value: unknown, key: string) => - typeof value === "object" && value !== null - ? (value as Record)[key] - : undefined; - const version = field(raw, "version"); - if (typeof version === "number" && version > currentVersion) - return `Record has version ${version}, but this Labkit build reads only version ${currentVersion}; ${newerBuild}`; - if (typeof version === "number" && version < currentVersion) - return `Record has version ${version}, an older journal format this Labkit build no longer reads (version ${currentVersion} only, no migration); start a new session`; - const kind = field(field(raw, "body"), "kind"); - if (typeof kind === "string" && !recordKinds.has(kind)) - return `Record has kind ${JSON.stringify(kind)}, which this Labkit build does not know; ${newerBuild}`; - return `Record does not decode as a version ${currentVersion} journal record`; -} diff --git a/packages/core-agent/session/journal/domain-event.ts b/packages/core-agent/session/journal/domain-event.ts deleted file mode 100644 index 81269b6e..00000000 --- a/packages/core-agent/session/journal/domain-event.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ConversationEvent } from "../../agent/agent-conversation.ts"; -import { completeResults } from "../../agent/tool-batch.ts"; -import type { WireEvent } from "../types.ts"; -import { partialResults } from "./shared.ts"; -import type { Fold, JournalState } from "./state.ts"; - -/** - * Converts a {@link WireEvent} into the richer event the turn machine reads: - * `model_settled` gets a `permissionRequired` flag derived fresh from the - * policy in force (never stored, so there is nothing to compare against on load), and - * `batch_settled` gets its results assembled from the batch's committed `tool` records (never - * stored on the wire either). A failed dispatch of a turn's child operation becomes a `failed` - * child event. - * - * @throws Error for a failed branch reply, which has no journaled form. - */ -export function domainEvent(state: JournalState, event: WireEvent, fold: Fold): ConversationEvent { - if (event.type !== "child") return event; - const child = event.event; - if (child.type === "model_settled") { - if (fold.mode === "stage" && child.result.kind === "succeeded") { - const result = child.result.value; - const c = state.conversation; - const activeAgent = c.turn.status === "idle" ? c.turn.agent : c.turn.turn.agent; - const agent = state.configuration.agents.find( - ([id]: readonly [string, unknown]) => id === activeAgent, - )?.[1]; - if ( - result.kind === "handoff" && - !( - agent?.successors ?? - state.configuration.agents.map(([id]: readonly [string, unknown]) => id) - ).includes(result.agent) - ) - throw new Error("Unknown handoff agent"); - if ( - result.kind === "tools" && - result.calls.some( - (call) => !(state.policy?.tools[activeAgent] ?? agent?.tools)?.includes(call.name), - ) - ) - throw new Error("Unpermitted tool"); - } - // The permission route is always derived from the policy in force, both when staging and on - // load; it is never stored, so there is nothing for a stored copy to disagree with. - const permissionRequired = - state.policy?.permissions === "ask" && - child.result.kind === "succeeded" && - child.result.value.kind === "tools"; - return { - ...event, - event: { ...child, ...(permissionRequired ? { permissionRequired: true as const } : {}) }, - }; - } - if (child.type !== "batch_settled") return { ...event, event: child }; - // Individual results are never stored on the wire; both staging and load assemble them from the - // batch's committed `tool` records. - const results = partialResults(state); - if (child.outcome.kind !== "succeeded") - return { ...event, event: { ...child, outcome: { ...child.outcome, results } } }; - const turn = state.conversation.turn; - if (turn.status !== "executing_tools" && turn.status !== "cancelling_tools") - throw new Error("Batch outside tool phase"); - const message = turn.turn.messages.at(-1); - if (message?.role !== "assistant" || !message.calls) throw new Error("Missing tool intents"); - return { - ...event, - event: { - ...child, - outcome: { kind: "succeeded", results: completeResults(message.calls, results) }, - }, - }; -} diff --git a/packages/core-agent/session/journal/reduce-boundary.ts b/packages/core-agent/session/journal/reduce-boundary.ts deleted file mode 100644 index 54d831a1..00000000 --- a/packages/core-agent/session/journal/reduce-boundary.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - bindingPolicyFields, - resolverPolicyFields, - validatePolicy, - type Policy, -} from "../../policy/policy.ts"; -import type { Fold, JournalState, ReducibleBody, Reduction } from "./state.ts"; - -/** - * Reduces a policy record: a "configuration applied" fact holding the resulting settings. The - * runtime only ever stages one at an idle boundary (selection is committed to a separate - * configuration store outside the journal), so stage revalidates the resulting policy against the - * live configuration and resolvers rather than re-deriving it from a patch. Updates the - * conversation's allowance to match and preserves the idle turn's step limit. - */ -function reducePolicy( - state: JournalState, - input: Extract, - fold: Fold, -): Reduction { - const c = state.conversation; - // Load: the stored policy is the policy, whatever today's rules would derive. - let policy = input.policy; - if (fold.mode === "stage") { - if (c.turn.status !== "idle") throw new Error("Configuration applies only at an idle boundary"); - policy = validatePolicy(input.policy, state.configuration, fold.resolvers); - } - return { - state: { - ...state, - policy, - pendingInputs: state.pendingInputs ?? [], - // A running turn keeps the allowance it started with. - conversation: { - ...c, - allowance: policy.steps, - turn: c.turn.status === "idle" ? { ...c.turn, steps: policy.steps } : c.turn, - }, - }, - commands: [], - }; -} - -/** - * Reduces a configuration record: updates the agents registry, policy, and idle - * agent binding. Validates that the current agent is in the new registry and reconciles - * only tool permissions, resolvers, and binding selections. - */ -function reduceConfiguration( - state: JournalState, - input: Extract, - fold: Fold, -): Reduction { - const c = state.conversation; - if (fold.mode === "stage" && (c.turn.status !== "idle" || state.pendingInputs?.length)) - throw new Error("Configuration changes require an idle boundary"); - // The switch replaces the idle conversation's agent; a running turn has none to replace. - if (input.agent !== undefined && c.turn.status !== "idle") - throw new Error("Configuration agent switch during a running turn"); - const current = c.turn.status === "idle" ? c.turn.agent : c.turn.turn.agent; - const agent = input.agent ?? current; - // Load: the stored configuration, policy and agent replace the folded ones as written. - let policy = input.policy ?? state.policy; - if (fold.mode === "stage") { - const registered = new Set(input.configuration.agents.map(([id]) => id)); - // An agent switch is recorded only when the idle conversation's agent was unregistered. - if (input.agent !== undefined && registered.has(current)) - throw new Error("Configuration agent switch requires an unregistered current agent"); - if (!registered.has(agent)) throw new Error(`Configuration omits the current agent: ${agent}`); - if (input.policy) { - // Reconciliation may rewrite only tool permissions, resolver IDs and binding selections. - const previous = state.policy; - const keys = new Set([...Object.keys(previous ?? {}), ...Object.keys(input.policy)]); - for (const key of ["tools", "version", ...resolverPolicyFields, ...bindingPolicyFields]) - keys.delete(key); - if ( - !previous || - input.policy.version !== previous.version + 1 || - [...keys].some( - (key) => - JSON.stringify(previous[key as keyof Policy]) !== - JSON.stringify(input.policy![key as keyof Policy]), - ) - ) - throw new Error( - "Configuration policy may only reconcile tools, resolvers and binding selections", - ); - policy = validatePolicy(input.policy, input.configuration, fold.resolvers); - } else if (state.policy) validatePolicy(state.policy, input.configuration, fold.resolvers); - } - return { - state: { - ...state, - configuration: input.configuration, - policy, - pendingInputs: state.pendingInputs ?? [], - conversation: - c.turn.status === "idle" && agent !== c.turn.agent - ? { ...c, turn: { ...c.turn, agent } } - : c, - }, - commands: [], - }; -} - -/** - * Reduces a system record: updates standing session instructions and version. - * Load requires version to continue; stage requires version to increment by one. - */ -function reduceSystem( - state: JournalState, - input: Extract, - fold: Fold, -): Reduction { - if (fold.mode === "stage") { - if (state.conversation.turn.status !== "idle") - throw new Error("System inputs require idle boundary"); - if (input.version !== state.systemVersion + 1) throw new Error("Invalid system version"); - } - return { - state: { ...state, systemInputs: input.inputs, systemVersion: input.version }, - commands: [], - }; -} - -export { reducePolicy, reduceConfiguration, reduceSystem }; diff --git a/packages/core-agent/session/journal/reduce-event.ts b/packages/core-agent/session/journal/reduce-event.ts deleted file mode 100644 index a5369f7e..00000000 --- a/packages/core-agent/session/journal/reduce-event.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { decideConversation } from "../../agent/agent-conversation.ts"; -import type { AgentMessage } from "../../agent/types.ts"; -import { ContinuationSchema } from "../../providers/types.ts"; -import { domainEvent } from "./domain-event.ts"; -import { replaceLastMessage, withUserParts } from "./shared.ts"; -import type { Fold, JournalState, ReducibleBody, Reduction } from "./state.ts"; - -/** - * Reduces an event record: processes a domain event (user input, completion, tool batch, - * permission decision, system notice, or child operation result), updates conversation state, and - * collects continuation data if applicable. Stage validates the system version, barge-in policy, - * and continuation consistency. The policy in force cannot change mid-turn (configuration only - * applies at an idle boundary), so events carry no policy stamp to re-derive. - */ -function reduceEvent( - state: JournalState, - input: Extract, - fold: Fold, -): Reduction { - if (fold.mode === "stage") { - if (input.systemVersion !== state.systemVersion) - throw new Error("Turn system version mismatch"); - if ( - input.event.type === "user" && - state.policy && - state.conversation.turn.status !== "idle" && - (!state.policy.bargeIn || state.policy.admission === "queue-user") - ) - throw new Error("Policy rejects barge-in"); - } - const settled = - input.event.type === "child" && input.event.event.type === "model_settled" - ? input.event.event - : undefined; - const envelope = settled?.continuation; - if (envelope) { - ContinuationSchema.parse(envelope); - const active = state.conversation.turn; - if ( - fold.mode === "stage" && - (settled?.result.kind !== "succeeded" || - active.status !== "awaiting_model" || - envelope.owner.turnId !== state.conversation.turnId || - envelope.owner.generation !== active.turn.generation || - envelope.provider !== state.policy?.provider || - state.continuations?.some( - (entry) => - entry.owner.turnId === envelope.owner.turnId && - entry.owner.generation === envelope.owner.generation, - )) - ) - throw new Error("Continuation owner/provider mismatch"); - // Load keeps the stored owner, but the envelope needs the assistant message its completion made. - if (settled?.result.kind !== "succeeded") - throw new Error("Continuation without an admitted completion"); - } - let decision = decideConversation(state.conversation, domainEvent(state, input.event, fold)); - if (input.event.type === "user") - decision = withUserParts(decision, input.event.text, input.event.attachments); - if (envelope) - decision = replaceLastMessage(decision, (message: AgentMessage) => { - if (message.role !== "assistant") throw new Error("Completion did not produce an assistant"); - return { ...message, owner: envelope.owner }; - }); - return { - state: { - ...state, - conversation: decision.state, - ...(envelope ? { continuations: [...(state.continuations ?? []), envelope] } : {}), - partial: - (input.event.type === "child" && input.event.event.type === "batch_settled") || - decision.state.sequence !== state.conversation.sequence - ? [] - : state.partial, - }, - commands: decision.commands, - }; -} - -/** - * Reduces a usage effect record: it becomes the latest completion accounting, whatever state the - * conversation is in. Its turn and operation IDs are correlation data, not preconditions; the - * conversation fold never reads the record, and it never gates load or new work. - */ -function reduceEffect( - state: JournalState, - input: Extract, - _fold: Fold, -): Reduction { - return { - state: { - ...state, - lastCompletionUsage: { - turnId: input.turnId, - operationId: input.operationId, - usage: input.usage, - }, - }, - commands: [], - }; -} - -export { reduceEvent, reduceEffect }; diff --git a/packages/core-agent/session/journal/reduce-queue.ts b/packages/core-agent/session/journal/reduce-queue.ts deleted file mode 100644 index 15e793a1..00000000 --- a/packages/core-agent/session/journal/reduce-queue.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { decideConversation } from "../../agent/agent-conversation.ts"; -import { withUserParts } from "./shared.ts"; -import type { Fold, JournalState, ReducibleBody, Reduction } from "./state.ts"; - -/** - * Reduces a queued input record: appends a pending input (text, attachments, inputId) - * to the queue. Stage requires the turn to be running and policy to permit queueing. - */ -function reduceQueued( - state: JournalState, - input: Extract, - fold: Fold, -): Reduction { - const c = state.conversation; - if (fold.mode === "stage") { - if ( - !state.policy || - input.policyVersion !== state.policy.version || - c.turn.status === "idle" || - state.records.some( - (record) => record.body.kind === "queued" && record.body.inputId === input.inputId, - ) - ) - throw new Error("Invalid queued input"); - if ( - state.policy.admission !== "queue-user" && - !( - state.policy.admission === "abort-tools-on-user" && - ["awaiting_permission", "executing_tools", "cancelling_tools"].includes(c.turn.status) - ) - ) - throw new Error("Policy does not queue this input"); - } - return { - state: { - ...state, - pendingInputs: [ - ...(state.pendingInputs ?? []), - { - inputId: input.inputId, - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - }, - ], - }, - commands: [], - }; -} - -/** - * Reduces an input_cancelled record: removes a pending input by inputId. - */ -function reduceInputCancelled( - state: JournalState, - input: Extract, - _fold: Fold, -): Reduction { - if (!state.pendingInputs?.some((entry) => entry.inputId === input.inputId)) - throw new Error("Unknown cancelled input"); - return { - state: { - ...state, - pendingInputs: state.pendingInputs.filter((entry) => entry.inputId !== input.inputId), - }, - commands: [], - }; -} - -/** - * Reduces a dequeued input record: dequeues and processes a pending input, starting a - * new turn with its text and attachments. Stage requires the turn to be idle and this - * input to be at the head of the queue. - */ -function reduceDequeued( - state: JournalState, - input: Extract, - fold: Fold, -): Reduction { - if ( - fold.mode === "stage" && - (state.conversation.turn.status !== "idle" || - state.pendingInputs?.[0]?.inputId !== input.inputId || - input.policyVersion !== state.policy?.version) - ) - throw new Error("Invalid dequeue boundary"); - // Load needs only the queued input whose text starts the turn. - const pending = state.pendingInputs?.find((entry) => entry.inputId === input.inputId); - if (!pending) throw new Error(`Dequeued input ${input.inputId} is not queued`); - const decision = withUserParts( - decideConversation(state.conversation, { type: "user", text: pending.text }), - pending.text, - pending.attachments, - ); - return { - state: { - ...state, - conversation: decision.state, - pendingInputs: state.pendingInputs!.filter((entry) => entry !== pending), - }, - commands: decision.commands, - }; -} - -export { reduceQueued, reduceInputCancelled, reduceDequeued }; diff --git a/packages/core-agent/session/journal/reduce-tools.ts b/packages/core-agent/session/journal/reduce-tools.ts deleted file mode 100644 index c4b548a6..00000000 --- a/packages/core-agent/session/journal/reduce-tools.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { decideConversation } from "../../agent/agent-conversation.ts"; -import { failure, MessagesSchema } from "../../agent/types.ts"; -import { partialResults } from "./shared.ts"; -import type { Fold, JournalState, ReducibleBody, Reduction } from "./state.ts"; - -/** - * Reduces a tool result record: appends the tool result to the partial batch. - */ -function reduceTool( - state: JournalState, - input: Extract, - _fold: Fold, -): Reduction { - return { state: { ...state, partial: [...state.partial, input] }, commands: [] }; -} - -/** - * Reduces a recovery record: resumes a turn that encountered an error or was interrupted, - * collecting partial tool results and passing the failure to the model's conversation state. - */ -function reduceRecovery( - state: JournalState, - input: Extract, - fold: Fold, -): Reduction { - const c = state.conversation; - if (input.turnId !== c.turnId) throw new Error("Recovery turn mismatch"); - if (c.turn.status === "idle") { - if (fold.mode === "stage" && !state.pendingInputs?.length) - throw new Error("Recovery requires interrupted work"); - return { state, commands: [] }; - } - const messages = MessagesSchema.parse([ - ...c.turn.turn.messages, - ...partialResults(state).map((result) => ({ - role: "tool", - callId: result.callId, - text: result.text, - ...(result.parts ? { parts: result.parts } : {}), - })), - ]); - const recovered = decideConversation( - { - ...c, - pending: [], - turn: { ...c.turn, turn: { ...c.turn.turn, messages } }, - }, - { - type: "child", - turnId: c.turnId, - event: { - type: "failed", - child: c.turn.child, - error: failure(input.reason, { - classification: "interrupted", - phase: c.turn.status, - operation: { ...c.turn.child, sessionId: c.sessionId, turnId: c.turnId }, - }), - }, - }, - ); - return { state: { ...state, conversation: recovered.state, partial: [] }, commands: [] }; -} - -export { reduceTool, reduceRecovery }; diff --git a/packages/core-agent/session/journal/reduce.ts b/packages/core-agent/session/journal/reduce.ts deleted file mode 100644 index 23d12a6c..00000000 --- a/packages/core-agent/session/journal/reduce.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { reduceConfiguration, reducePolicy, reduceSystem } from "./reduce-boundary.ts"; -import { reduceEffect, reduceEvent } from "./reduce-event.ts"; -import { reduceDequeued, reduceInputCancelled, reduceQueued } from "./reduce-queue.ts"; -import { reduceRecovery, reduceTool } from "./reduce-tools.ts"; -import { accepts, missingTarget } from "./shared.ts"; -import type { Fold, JournalState, Reducer, ReducibleBody, Reduction } from "./state.ts"; - -const reducers: { readonly [K in ReducibleBody["kind"]]: Reducer } = { - policy: reducePolicy, - configuration: reduceConfiguration, - system: reduceSystem, - queued: reduceQueued, - input_cancelled: reduceInputCancelled, - dequeued: reduceDequeued, - tool: reduceTool, - effect: reduceEffect, - recovery: reduceRecovery, - event: reduceEvent, -}; - -/** - * Applies one journal body to the state through its registered reducer. Staging first requires - * the input to be accepted by the commit-time rules; loading only requires its target to exist. - * An effect record has no precondition: its turn and operation IDs are correlation data, and it - * never gates load or new work. - */ -export function reduce(state: JournalState, input: ReducibleBody, fold: Fold): Reduction { - if (input.kind !== "effect") { - if (fold.mode === "stage") { - if (!accepts(state, input)) throw new Error("Stale or uncorrelated journal input"); - } else { - const missing = missingTarget(state, input); - if (missing) throw new Error(missing); - } - } - return (reducers[input.kind] as Reducer)(state, input as never, fold); -} diff --git a/packages/core-agent/session/journal/render.ts b/packages/core-agent/session/journal/render.ts deleted file mode 100644 index be1d92a9..00000000 --- a/packages/core-agent/session/journal/render.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { AgentMessage } from "../../agent/types.ts"; -import { projectMessage } from "../views.ts"; -import { encodeRecord } from "./codec.ts"; -import type { JournalState } from "./state.ts"; - -/** The records as JSON Lines: one encoded record per line in revision order, newline-terminated. */ -export function journalJSONL(state: JournalState): string { - return `${state.records.map(encodeRecord).join("\n")}\n`; -} - -/** - * Renders the journal as a Markdown report for people: agent system prompts, standing instruction - * history, context, turn log, any unfinished turn, permission decisions, recoveries and registry - * adoptions. A readable view only; the journal remains the authoritative record. - * - * @param options.agentLabels Display names by agent ID. - */ -export function journalMarkdown( - state: JournalState, - options: { agentLabels?: Readonly> } = {}, -): string { - const c = state.conversation; - const label = (id: string) => - options.agentLabels?.[id] ? `${options.agentLabels[id]} (agent ID: ${id})` : `Agent ${id}`; - const block = (value: unknown) => { - const text = JSON.stringify(value, null, 2); - const fence = "`".repeat( - Math.max(3, ...Array.from(text.matchAll(/`+/g), (m) => m[0].length + 1)), - ); - return `${fence}json\n${text}\n${fence}`; - }; - const quote = (text: string) => - text - .split("\n") - .map((line) => `> ${line}`) - .join("\n"); - const message = (m: AgentMessage): string[] => { - const view = projectMessage(m); - const title = - view.role === "tool" - ? `Tool result — call ${view.callId}` - : view.role[0]!.toUpperCase() + view.role.slice(1); - let content = quote(view.text); - if (view.role === "tool") { - // Tool messages are text at this boundary. Pretty-print JSON when present, retaining literal text otherwise. - try { - const parsed: unknown = JSON.parse(view.text); - if (parsed !== null && typeof parsed === "object") content = block(parsed); - } catch { - /* Literal tool text is valid and is rendered unchanged. */ - } - } - const lines = [`### ${title}`, "", ...(view.text ? [content, ""] : [])]; - if (view.role === "assistant" && view.calls) { - for (const call of view.calls) - lines.push(`**Tool call: ${call.name}** (call ID: ${call.id})`, "", block(call.args), ""); - } - for (const blob of view.blobs) - lines.push( - `Attachment: **${blob.name ?? "Unnamed attachment"}** — ${blob.media}, ${blob.bytes} bytes.`, - "", - `Blob URI: \`${blob.uri}\` (bytes are stored separately).`, - "", - ); - return lines; - }; - const lines = [ - `# Session ${c.sessionId}`, - "", - `Revision: ${state.revision}. System instruction version: ${state.systemVersion}.`, - "", - ]; - if (c.origin.kind === "root") lines.push("Origin: new root session.", ""); - else - lines.push( - `Origin: ${c.origin.kind} of session \`${c.origin.parent}\` at parent sequence ${c.origin.sequence}.`, - "", - ); - lines.push("## Agent system prompts", ""); - for (const [id, agent] of state.configuration.agents) - lines.push( - `### ${label(id)}`, - "", - agent.systemPrompt ? quote(agent.systemPrompt) : "No configured system prompt.", - "", - ); - const instructionRecords = state.records.flatMap((record) => { - const body = record.body; - if (body.kind === "created") - return [ - { - revision: record.revision, - version: body.seed.systemVersion, - inputs: body.seed.systemInputs, - }, - ]; - if (body.kind === "system") - return [{ revision: record.revision, version: body.version, inputs: body.inputs }]; - return []; - }); - lines.push("## Session instruction history", ""); - for (const entry of instructionRecords) - lines.push( - `### Revision ${entry.revision} — instruction version ${entry.version}`, - "", - ...(entry.inputs.length - ? entry.inputs.flatMap((text) => [quote(text), ""]) - : ["No shared instructions.", ""]), - ); - if (state.systemInputs.length) - lines.push( - "## Shared instructions", - "", - ...state.systemInputs.flatMap((text) => [quote(text), ""]), - ); - if (c.context.length) - lines.push("## Inherited or replacement context", "", ...c.context.flatMap(message)); - if (!c.log.length) lines.push("No terminal turns have been committed.", ""); - c.log.forEach((record, index) => { - lines.push( - `## Turn ${index + 1} — ${record.outcome.kind}`, - "", - `Responsible agent: ${label(record.agent)}.`, - "", - ...record.messages.flatMap(message), - ); - const outcome = record.outcome; - lines.push( - `**Outcome:** ${outcome.kind}${outcome.kind === "failed" ? ` — ${outcome.error.message}` : outcome.kind === "exhausted" ? " — model-step allowance reached" : outcome.kind === "aborted" ? " — turn cancelled" : ""}.`, - "", - ); - }); - if (c.turn.status !== "idle") - lines.push( - `## Unfinished turn — ${c.turn.status}`, - "", - ...c.turn.turn.messages.flatMap(message), - ...state.partial.flatMap((entry) => [ - "Committed partial tool outcome:", - "", - block(entry), - "", - ]), - ); - const permissions = state.records.flatMap(({ body }) => { - if ( - body.kind !== "event" || - body.event.type !== "child" || - body.event.event.type !== "permission_settled" - ) - return []; - const result = body.event.event.result; - return [ - `Turn ID: \`${body.event.turnId}\``, - "", - ...(result.kind === "succeeded" - ? result.value.map( - (decision) => - `- Call ${decision.callId}: **${decision.decision}**${decision.decision === "invalid_input" ? ` — ${decision.error.message}` : decision.approval ? ` — ${decision.approval.scope}, ${decision.approval.source}, grant ${decision.approval.grantId}` : ""}`, - ) - : [ - `Permission operation: ${result.kind}${result.kind === "failed" ? ` — ${result.error.message}` : ""}.`, - ]), - "", - ]; - }); - if (permissions.length) lines.push("## Recorded permission decisions", "", ...permissions); - for (const { body, revision } of state.records) { - if (body.kind === "recovery") lines.push("## Recovery", "", quote(body.reason), ""); - if (body.kind === "configuration") - lines.push( - `## Registry adopted at revision ${revision}`, - "", - `Agents: ${body.configuration.agents.map(([id]: [string, unknown]) => id).join(", ")}.`, - "", - `Tools: ${body.configuration.tools.map(([name]: [string, unknown]) => name).join(", ") || "none"}.`, - "", - ...(body.agent ? [`Conversation continues with ${label(body.agent)}.`, ""] : []), - ...(body.policy - ? [`Tool permissions reconciled as policy version ${body.policy.version}.`, ""] - : []), - ); - } - return `${lines.join("\n").trimEnd()}\n`; -} diff --git a/packages/core-agent/session/journal/replay.ts b/packages/core-agent/session/journal/replay.ts deleted file mode 100644 index 41db11f2..00000000 --- a/packages/core-agent/session/journal/replay.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { freeze } from "../../fsm/fsm.ts"; -import type { CommittedBatch } from "../persistence.ts"; -import type { JournalRecord } from "../types.ts"; -import { decodeFailure, decodeRecord } from "./codec.ts"; -import { reduce } from "./reduce.ts"; -import { foldSeed } from "./seed.ts"; -import { load } from "./state.ts"; -import type { JournalState } from "./state.ts"; - -/** - * Integrity rules a committed journal must satisfy to load. Nothing else is checked on load; the - * commit-time rules run only when new work is staged. "Batch" in these rules is a journal append - * batch (`CommittedBatch`), except in `record_applicable`, where it is a tool batch. - */ -export type JournalIntegrityRule = - /** Each append ID is committed once. */ - | "append_unique" - /** An append batch is non-empty and continues the journal at its expected revision. */ - | "batch_continuity" - /** A record decodes as a current-format journal record. */ - | "record_decode" - /** Every record belongs to the batch's and the creation record's session. */ - | "session_identity" - /** A record carries its batch's append ID. */ - | "append_identity" - /** Record revisions increase by one from 1. */ - | "revision_sequence" - /** An entry ID is `/`; unique append IDs make entry IDs unique. */ - | "entry_format" - /** The first record, and only the first, creates the session. */ - | "creation_first" - /** A record that ends a turn is followed by that turn's terminal record. */ - | "terminal_required" - /** A terminal record follows a record that ended its turn. */ - | "terminal_unexpected" - /** A turn's terminal record is committed in the same append as the record that ended the turn. */ - | "terminal_same_batch" - /** - * The record applies to the folded state: it names a turn, operation, tool batch, call or queued - * input the state has. Any other error while folding a record is reported under this rule too. - */ - | "record_applicable"; - -/** Position of an offending record: its own fields, or where it was expected when undecodable. */ -export type JournalLocation = Readonly<{ revision?: number; appendId?: string; entryId?: string }>; - -/** - * A committed journal that cannot load, naming the violated rule and the offending record. Thrown - * by {@link replay}. The message reads `Journal integrity () at : `. - */ -export class JournalIntegrityError extends Error { - readonly rule: JournalIntegrityRule; - /** Revision of the offending record, or where it was expected; absent for an empty journal. */ - readonly revision?: number; - /** Append ID of the offending record's batch; absent for an empty journal. */ - readonly appendId?: string; - /** Entry ID of the offending record, or where it was expected; absent for an empty journal. */ - readonly entryId?: string; - - constructor( - rule: JournalIntegrityRule, - detail: string, - at: JournalLocation, - options?: ErrorOptions, - ) { - super(`Journal integrity (${rule}) at ${at.entryId ?? "journal"}: ${detail}`, options); - this.name = "JournalIntegrityError"; - this.rule = rule; - this.revision = at.revision; - this.appendId = at.appendId; - this.entryId = at.entryId; - } -} - -/** - * Loads a journal: folds committed batches into state, checking only journal integrity - * ({@link JournalIntegrityRule}). Each stored record is taken as written. Commit-time rules - * (prompt projection, configuration validation, permissions, admission, bindings) are not re-run, - * so a journal the runtime committed keeps loading after code, configuration or bindings change. A - * turn that process exit interrupted stays open in the result; closing it is a separately staged - * `recovery` record. - * - * @throws {@link JournalIntegrityError} naming the first violated rule and the offending record. - */ -export function replay(batches: readonly CommittedBatch[]): JournalState { - let state: JournalState | undefined; - let ended: { turnId: string; at: JournalLocation } | undefined; - const appends = new Set(); - const fold = (at: JournalLocation, apply: () => JournalState) => { - try { - return apply(); - } catch (error) { - throw new JournalIntegrityError( - "record_applicable", - error instanceof Error ? error.message : String(error), - at, - { cause: error }, - ); - } - }; - for (const batch of batches) { - const revision = state?.revision ?? 0; - // A batch-level failure names the batch's first record as the batch itself describes it. - const first = { - revision: batch.expectedRevision + 1, - appendId: batch.appendId, - entryId: `${batch.appendId}/0`, - }; - if (appends.has(batch.appendId)) - throw new JournalIntegrityError( - "append_unique", - `Append ${batch.appendId} is committed twice`, - first, - ); - if ( - !batch.records.length || - batch.expectedRevision !== revision || - batch.revision !== batch.expectedRevision + batch.records.length - ) - throw new JournalIntegrityError( - "batch_continuity", - `Batch of ${batch.records.length} records from revision ${batch.expectedRevision} to ${batch.revision}; the journal is at revision ${revision}`, - first, - ); - appends.add(batch.appendId); - for (const [index, serialized] of batch.records.entries()) { - const expected = { - revision: (state?.revision ?? 0) + 1, - appendId: batch.appendId, - entryId: `${batch.appendId}/${index}`, - }; - let record: JournalRecord; - try { - record = decodeRecord(serialized); - } catch (error) { - throw new JournalIntegrityError("record_decode", decodeFailure(serialized), expected, { - cause: error, - }); - } - const at = { revision: record.revision, appendId: record.appendId, entryId: record.entryId }; - const sessionId = state?.conversation.sessionId ?? batch.sessionId; - if (record.sessionId !== batch.sessionId || record.sessionId !== sessionId) - throw new JournalIntegrityError( - "session_identity", - `Record of session ${record.sessionId} in the journal of session ${sessionId}`, - at, - ); - if (record.appendId !== batch.appendId) - throw new JournalIntegrityError( - "append_identity", - `Record of append ${record.appendId} in batch ${batch.appendId}`, - at, - ); - if (record.revision !== expected.revision) - throw new JournalIntegrityError( - "revision_sequence", - `Record revision ${record.revision} does not follow revision ${expected.revision - 1}`, - at, - ); - if (record.entryId !== expected.entryId) - throw new JournalIntegrityError( - "entry_format", - `Entry ID ${record.entryId} should be ${expected.entryId}`, - at, - ); - const body = record.body; - if (!state) { - if (body.kind !== "created") - throw new JournalIntegrityError( - "creation_first", - `The first record is ${body.kind}, not created`, - at, - ); - if (body.seed.sessionId !== record.sessionId) - throw new JournalIntegrityError( - "session_identity", - `Creation record seeds session ${body.seed.sessionId} in the journal of session ${record.sessionId}`, - at, - ); - const seed = body.seed; - state = fold(at, () => foldSeed(seed)); - } else if (body.kind === "created") { - throw new JournalIntegrityError("creation_first", "A second creation record", at); - } else if (ended) { - if (body.kind !== "terminal" || body.turnId !== ended.turnId) - throw new JournalIntegrityError( - "terminal_required", - `Turn ${ended.turnId} ended at ${ended.at.entryId}, but the next record is ${body.kind === "terminal" ? `the terminal record of turn ${body.turnId}` : body.kind}`, - at, - ); - // The terminal record is the turn-ended fact: its agent and outcome are what happened and - // are taken as written. The turn's messages are the fold's, built from the turn's facts. - const c = state.conversation; - const settled = c.log.at(-1)!; - state = { - ...state, - conversation: { - ...c, - log: [...c.log.slice(0, -1), { ...settled, agent: body.agent, outcome: body.outcome }], - turn: c.turn.status === "idle" ? { ...c.turn, agent: body.agent } : c.turn, - }, - }; - ended = undefined; - } else if (body.kind === "terminal") { - throw new JournalIntegrityError( - "terminal_unexpected", - `Terminal record of turn ${body.turnId}, but no turn ended`, - at, - ); - } else { - const before = state; - state = fold(at, () => reduce(before, body, load).state); - if (state.conversation.sequence !== before.conversation.sequence) - ended = { turnId: before.conversation.turnId, at }; - } - state = { ...state, revision: record.revision, records: [...state.records, record] }; - } - if (ended) - throw new JournalIntegrityError( - "terminal_same_batch", - `Turn ${ended.turnId} ended without a terminal record in append ${batch.appendId}`, - ended.at, - ); - } - if (!state) throw new JournalIntegrityError("creation_first", "The journal has no records", {}); - return freeze(state); -} diff --git a/packages/core-agent/session/journal/seed.ts b/packages/core-agent/session/journal/seed.ts deleted file mode 100644 index 560d4024..00000000 --- a/packages/core-agent/session/journal/seed.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { initialConversation, type ConversationState } from "../../agent/agent-conversation.ts"; -import { projectConversationPrompt } from "../../agent/prompt.ts"; -import { ActorIdSchema } from "../../agent/types.ts"; -import { freeze } from "../../fsm/fsm.ts"; -import { builtinResolvers, validatePolicy, type PolicyResolvers } from "../../policy/policy.ts"; -import { INITIAL_REVISION } from "../persistence.ts"; -import { SeedSchema, type Seed } from "../types.ts"; -import type { JournalState } from "./state.ts"; - -/** - * Builds the initial state of a new session from its seed under every commit-time rule: the turn - * sequence against origin and log, a registered starting agent, the policy against the registry and - * live `resolvers`, inherited continuation owners and provider bindings, and a history that - * projects into a prompt. Load folds a committed seed without these checks. - * - * @throws Error when the seed breaks a commit-time rule; ZodError when it does not parse. - */ -export function seedConversation( - raw: Seed, - resolvers: PolicyResolvers = builtinResolvers, -): JournalState { - const seed = SeedSchema.parse(raw); - if ( - seed.sequence !== seed.log.length + 1 || - (seed.origin.kind === "fork" && seed.sequence !== seed.origin.sequence) - ) - throw new Error("Invalid inherited sequence"); - if (!seed.configuration.agents.some(([id]) => id === seed.agent)) - throw new Error("Unknown seed agent"); - if (seed.origin.kind === "root" && (seed.sequence !== 1 || seed.log.length)) - throw new Error("Invalid root boundary"); - if (seed.origin.kind === "compaction" && (seed.sequence !== 1 || seed.log.length)) - throw new Error("Invalid compaction boundary"); - validatePolicy(seed.policy, seed.configuration, resolvers); - if (seed.continuations) { - const owners = new Set(); - for (const entry of seed.continuations) { - if (resolvers.providerIds && !resolvers.providerIds.has(entry.provider)) - throw new Error("Missing continuation provider binding"); - const key = JSON.stringify(entry.owner); - if ( - seed.origin.kind !== "fork" || - owners.has(key) || - ![...seed.context, ...seed.log.flatMap((record) => record.messages)].some( - (message) => - message.role === "assistant" && - message.owner?.turnId === entry.owner.turnId && - message.owner.generation === entry.owner.generation, - ) - ) - throw new Error("Invalid inherited continuation owner"); - owners.add(key); - } - } - const state = foldSeed(seed); - // Validate inherited history as well as the replacement context. - projectConversationPrompt({ - context: seed.context, - log: seed.log, - agent: { model: "validation", tools: [] }, - turn: { - id: state.conversation.turnId, - agent: seed.agent, - generation: 0, - steps: seed.policy.steps, - messages: [], - view: { kind: "history" }, - }, - }); - return state; -} - -/** Loading a creation record: the committed seed is the initial state, as written. */ -export function foldSeed(seed: Seed): JournalState { - const initial = initialConversation(seed.agent, seed.policy.steps, seed.sessionId); - const id = ActorIdSchema.parse(`${seed.sessionId}/turn/${seed.sequence}`); - const conversation: ConversationState = { - ...initial, - context: seed.context, - origin: seed.origin, - log: seed.log, - sequence: seed.sequence, - turnId: id, - turn: { ...initial.turn, status: "idle", id, agent: seed.agent, steps: seed.policy.steps }, - }; - return freeze({ - conversation, - ...(seed.continuations?.length ? { continuations: seed.continuations } : {}), - configuration: seed.configuration, - policy: seed.policy, - pendingInputs: [], - systemInputs: seed.systemInputs, - systemVersion: seed.systemVersion, - partial: [], - revision: INITIAL_REVISION, - records: [], - }); -} diff --git a/packages/core-agent/session/journal/shared.ts b/packages/core-agent/session/journal/shared.ts deleted file mode 100644 index a674b2a3..00000000 --- a/packages/core-agent/session/journal/shared.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { ConversationCommand, decideConversation } from "../../agent/agent-conversation.ts"; -import type { BlobRef, ContentPart } from "../../agent/content.ts"; -import type { AgentMessage, TurnData, TurnRecord } from "../../agent/types.ts"; -import { effectiveToolResult } from "../../policy/policy.ts"; -import type { SessionInput } from "../types.ts"; -import type { JournalState } from "./state.ts"; - -/** - * Why a record cannot apply: it names a turn, operation, batch or tool call that does not exist in - * the folded state. Load checks only this; staging (`accepts`) adds the commit-time rules. - */ -export function missingTarget(state: JournalState, input: SessionInput): string | undefined { - const c = state.conversation; - if (input.kind === "event" && input.event.type === "child") { - const { turnId, event } = input.event; - if (turnId !== c.turnId) return `Event for turn ${turnId}; the current turn is ${c.turnId}`; - if (c.turn.status === "idle") - return `Event for ${event.child.kind} ${event.child.id}; turn ${turnId} is idle`; - if (event.child.id !== c.turn.child.id || event.child.kind !== c.turn.child.kind) - return `Event for ${event.child.kind} ${event.child.id}; the active operation is ${c.turn.child.kind} ${c.turn.child.id}`; - return undefined; - } - if (input.kind !== "tool") return undefined; - if (input.turnId !== c.turnId) - return `Tool result for turn ${input.turnId}; the current turn is ${c.turnId}`; - if (c.turn.status !== "executing_tools" && c.turn.status !== "cancelling_tools") - return `Tool result for batch ${input.batchId}; turn ${c.turnId} has no tool batch`; - if (input.batchId !== c.turn.child.id) - return `Tool result for batch ${input.batchId}; the active batch is ${c.turn.child.id}`; - const intents = c.turn.turn.messages.at(-1); - if (intents?.role !== "assistant" || !intents.calls?.some((call) => call.id === input.callId)) - return `Tool result for call ${input.callId}; batch ${input.batchId} has no such call`; - return undefined; -} - -/** - * Whether `input` still applies to `state`: the turn, operation, tool batch and call it names are - * current, and a tool result arrives while its batch runs, once per call, with no earlier result of - * the batch counting as failed under the policy's `toolFailure`. `false` marks a stale or - * uncorrelated input: `decideSession` answers `ignored` and {@link stage} throws. - */ -export function accepts(state: JournalState, input: SessionInput): boolean { - if (missingTarget(state, input)) return false; - if (input.kind !== "tool") return true; - // New results arrive only while the batch runs, once per call, and none after a failure. - return ( - state.conversation.turn.status === "executing_tools" && - !state.partial.some( - (entry) => - entry.callId === input.callId || - effectiveToolResult(entry.result, state.policy).kind !== "succeeded", - ) - ); -} - -export function partialResults(state: JournalState) { - return state.partial.flatMap((entry) => { - const result = effectiveToolResult(entry.result, state.policy); - return result.kind === "succeeded" ? [{ callId: entry.callId, ...result.value }] : []; - }); -} - -export function replaceLastMessage( - decision: ReturnType, - update: (message: AgentMessage) => AgentMessage, -): ReturnType { - const c = decision.state; - const messages = c.turn.status === "idle" ? c.log.at(-1)!.messages : c.turn.turn.messages; - const target = messages.at(-1)!; - const attachMessages = (items: readonly AgentMessage[]) => - items.map((message) => (message === target ? update(message) : message)); - const attachTurn = (turn: TurnData): TurnData => ({ - ...turn, - messages: attachMessages(turn.messages), - }); - const attachLog = (log: readonly TurnRecord[]) => - log.map((record) => ({ ...record, messages: attachMessages(record.messages) })); - // Keep the decorated message in history, handoff views and pending branch commands. - return { - state: { - ...c, - log: attachLog(c.log), - turn: - c.turn.status === "idle" - ? c.turn - : ({ ...c.turn, turn: attachTurn(c.turn.turn) } as typeof c.turn), - }, - commands: decision.commands.map( - (effect): ConversationCommand => - effect.type === "reply" - ? { - ...effect, - result: { - ...effect.result, - state: { ...effect.result.state, log: attachLog(effect.result.state.log) }, - }, - } - : { - ...effect, - command: - "turn" in effect.command - ? { ...effect.command, turn: attachTurn(effect.command.turn) } - : effect.command, - }, - ), - }; -} - -export function withUserParts( - decision: ReturnType, - text: string, - attachments?: readonly BlobRef[], -) { - if (!attachments) return decision; - const parts: readonly ContentPart[] = [ - ...(text ? [{ type: "text" as const, text }] : []), - ...attachments.map((ref) => ({ type: "blob" as const, ref })), - ]; - return replaceLastMessage(decision, (message) => { - if (message.role !== "user") throw new Error("Input did not produce a user message"); - return { ...message, parts }; - }); -} diff --git a/packages/core-agent/session/journal/stage.ts b/packages/core-agent/session/journal/stage.ts deleted file mode 100644 index 639f961b..00000000 --- a/packages/core-agent/session/journal/stage.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { ConversationCommand } from "../../agent/agent-conversation.ts"; -import { ActorIdSchema } from "../../agent/types.ts"; -import { freeze } from "../../fsm/fsm.ts"; -import { builtinResolvers, type PolicyResolvers } from "../../policy/policy.ts"; -import { RevisionSchema, type AppendId } from "../persistence.ts"; -import { JournalRecordSchema, type JournalBody, type Seed, type SessionInput } from "../types.ts"; -import { encodeRecord } from "./codec.ts"; -import { reduce } from "./reduce.ts"; -import { seedConversation } from "./seed.ts"; -import type { Fold, JournalState } from "./state.ts"; - -/** - * Stages new work: folds `input` into `state` under every commit-time rule, checked against the - * live resolvers. Inputs are admitted if they apply to the state and pass all policy checks. - * - * If `input.kind === "created"`, `stageCreation` is called instead. - * - * The state that is returned is ready to commit. Its `records` array holds the batch's encoded - * journal records in revision order, ready to append. The staged state is frozen and a proposal - * until the batch commits, after which it becomes the result of {@link replay}. Conversely, load - * is integrity-only: each stored record is taken as written, and never re-validated against live - * policy or code. - * - * @param state The current state, from {@link replay} or a prior staging. - * @param input The input to fold. - * @param appendId The append ID that will be assigned to this batch's records. - * @param resolvers Policy resolvers to use during rule checking; see {@link builtinResolvers}. - * @param inputId The actor ID, defaulted to `appendId`. - * @returns An object holding the frozen next `state`, the `records` array ready to append, and any - * `commands` that the model issued (e.g., a handoff tool call). - * @throws Error or ZodError on any validation error or missing target. - */ -export function stage( - state: JournalState, - input: SessionInput, - appendId: AppendId, - resolvers: PolicyResolvers = builtinResolvers, - inputId = ActorIdSchema.parse(appendId), -) { - if (input.kind === "created") { - if ( - state.revision !== 0 || - state.records.length || - input.seed.sessionId !== state.conversation.sessionId - ) - throw new Error("Creation requires an absent stream"); - return stageCreation(input.seed, appendId, resolvers); - } - const fold: Fold = { mode: "stage", resolvers }; - let next = state; - const bodies: JournalBody[] = []; - const commands: ConversationCommand[] = []; - const apply = (body: Exclude) => { - const before = next; - const decision = reduce(next, body, fold); - next = decision.state; - bodies.push(body); - commands.push(...decision.commands); - if (next.conversation.sequence !== before.conversation.sequence) { - const settled = next.conversation.log.at(-1)!; - bodies.push({ - kind: "terminal", - turnId: before.conversation.turnId, - agent: settled.agent, - outcome: settled.outcome, - }); - } - }; - if (input.kind === "policy") { - apply({ kind: "policy", policy: input.policy }); - } else if ( - input.kind === "event" && - input.event.type === "user" && - next.policy && - next.conversation.turn.status !== "idle" && - (next.policy.admission === "queue-user" || - (next.policy.admission === "abort-tools-on-user" && - ["awaiting_permission", "executing_tools", "cancelling_tools"].includes( - next.conversation.turn.status, - ))) - ) { - apply({ - kind: "queued", - inputId, - text: input.event.text, - ...(input.event.attachments ? { attachments: input.event.attachments } : {}), - policyVersion: next.policy.version, - }); - if ( - next.policy!.admission === "abort-tools-on-user" && - ["awaiting_permission", "executing_tools"].includes(next.conversation.turn.status) - ) - apply({ - kind: "event", - event: { type: "abort" }, - systemVersion: next.systemVersion, - }); - } else if (input.kind === "event") { - const { usage, ...body } = input; - apply(body); - // The step's output is the fact; the usage it reported is an effect staged after it in the same - // append (after the terminal record when the step ended the turn). The conversation fold never - // reads it, so it never gates load or new work. - if ( - usage && - body.event.type === "child" && - body.event.event.type === "model_settled" && - body.event.event.result.kind === "succeeded" - ) - apply({ - kind: "effect", - turnId: body.event.turnId, - operationId: body.event.event.child.id, - effect: "usage", - usage, - }); - } else { - apply(input); - } - if (input.kind === "recovery") - for (const pending of next.pendingInputs ?? []) - apply({ kind: "input_cancelled", inputId: pending.inputId, reason: input.reason }); - return packageRecords(state, next, bodies, appendId, commands); -} - -function packageRecords( - previous: JournalState, - next: JournalState, - bodies: readonly JournalBody[], - appendId: AppendId, - commands: readonly ConversationCommand[] = [], -) { - const records = bodies.map((body, index) => { - return JournalRecordSchema.parse({ - version: 2, - sessionId: previous.conversation.sessionId, - revision: previous.revision + index + 1, - entryId: `${appendId}/${index}`, - appendId, - body, - }); - }); - const revision = RevisionSchema.parse(previous.revision + records.length); - return freeze({ - state: { ...next, revision, records: [...previous.records, ...records] }, - records: records.map(encodeRecord), - commands, - }); -} - -/** - * Stages the `created` record of a new session: {@link seedConversation} under the commit-time - * rules, as a one-record append at revision 1. Load folds the committed seed without these rules. - * - * @throws Error or ZodError, as {@link seedConversation} does. - */ -export function stageCreation( - seed: Seed, - appendId: AppendId, - resolvers: PolicyResolvers = builtinResolvers, -) { - const state = seedConversation(seed, resolvers); - return packageRecords(state, state, [{ kind: "created", seed }], appendId); -} diff --git a/packages/core-agent/session/journal/state.ts b/packages/core-agent/session/journal/state.ts deleted file mode 100644 index e2d2d6bd..00000000 --- a/packages/core-agent/session/journal/state.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { ConversationCommand, ConversationState } from "../../agent/agent-conversation.ts"; -import type { BlobRef } from "../../agent/content.ts"; -import type { ActorIdSchema } from "../../agent/types.ts"; -import type { Policy, PolicyResolvers } from "../../policy/policy.ts"; -import type { Continuation } from "../../providers/types.ts"; -import type { CompletionUsage } from "../../providers/usage.ts"; -import type { Revision } from "../persistence.ts"; -import type { Configuration, JournalBody, JournalRecord, SystemVersionSchema } from "../types.ts"; - -/** A `tool` record's body: one tool call's raw result, committed before its tool batch settles. */ -export type ToolEntry = Extract; - -/** Accounting of the latest committed completion that reported usage. */ -export type LastCompletionUsage = Readonly<{ - /** Turn whose step produced the completion. */ - turnId: string; - /** ID of the completion: a child operation of the turn, not a child session. */ - operationId: string; - usage: CompletionUsage; -}>; - -/** - * Session state folded from journal records: the conversation plus everything the records set. - * {@link replay} builds it on load; {@link stage} returns the next one for new work. A staged state - * is a proposal until its append commits. - */ -export type JournalState = Readonly<{ - /** Accounting of the latest committed completion that reported usage; kept when one has none. */ - lastCompletionUsage?: LastCompletionUsage; - /** Current turn, turn log, inherited context and pending branch requests. */ - conversation: ConversationState; - /** - * Provider continuation payloads (such as thinking signatures) committed with settled steps, each - * owned by the assistant message of one turn and generation. - */ - continuations?: readonly Continuation[]; - /** - * Configuration (the user-selectable settings) in force. Policy records are staged only at an - * idle boundary, so a running turn keeps the policy it started with. - */ - policy: Policy; - /** - * Queued inputs, oldest first, each waiting for the current turn to end. Not the in-memory - * submissions of `SessionState.queue`. - */ - pendingInputs?: readonly Readonly<{ - inputId: ReturnType; - text: string; - attachments?: readonly BlobRef[]; - }>[]; - /** The registry (tool and agent definitions); the user-selectable configuration is `policy`. */ - configuration: Configuration; - /** Standing session instructions, not system notices. */ - systemInputs: readonly string[]; - systemVersion: ReturnType; - /** - * `tool` records committed for the running tool batch, in commit order. Cleared when the batch - * settles or the turn ends. - */ - partial: readonly ToolEntry[]; - /** Revision of the last record in `records`; 0 before the creation record. */ - revision: Revision; - /** Every record folded so far, in revision order. A staged state ends with uncommitted ones. */ - records: readonly JournalRecord[]; -}>; - -/** - * How records fold into state. `stage` admits new work and enforces every commit-time rule against - * the live resolvers. `load` rebuilds state from committed records: each stored record is taken as - * written, and only what the fold needs to apply it is checked. - */ -export type Fold = - | Readonly<{ mode: "stage"; resolvers: PolicyResolvers }> - | Readonly<{ mode: "load" }>; - -export const load: Fold = { mode: "load" }; - -export type ReducibleBody = Exclude; - -export type Reduction = { - state: JournalState; - commands: readonly ConversationCommand[]; -}; - -export type Reducer = ( - state: JournalState, - input: Extract, - fold: Fold, -) => Reduction; diff --git a/packages/core-agent/session/observability.test.ts b/packages/core-agent/session/observability.test.ts deleted file mode 100644 index cde81fa2..00000000 --- a/packages/core-agent/session/observability.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { getLogger } from "@logtape/logtape"; -import { expect, spyOn, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import type { EffectEvent } from "../effects/index.ts"; -import { defineTool } from "../host/ports.ts"; -import { createSession, restoreSession } from "./session-runtime.ts"; -import { - deferred, - deterministicIds, - lostAcknowledgement, - testOptions, - until, -} from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -function observeLogs() { - const records: { event: string; level: string; fields: Record }[] = []; - const spies = ["host", "session", "persistence", "provider"].flatMap((category) => { - const logger = getLogger(["labkit", category]); - const original = logger.emit.bind(logger); - return [ - spyOn(logger, "emit").mockImplementation((record) => { - records.push({ - event: String(record.rawMessage), - level: record.level, - fields: record.properties, - }); - original(record); - }), - ]; - }); - return { - records, - close: () => - spies.forEach((spy) => { - spy.mockRestore(); - }), - }; -} - -test("permission logs identify the file, wait, refusal and the completed turn", async () => { - const capture = observeLogs(); - const gate = deferred(); - let ran = false; - let completions = 0; - const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { - agent: "a", - agents: new Map([["a", { model: "m", tools: ["read"] }]]), - steps: 3, - policy: { permissions: "ask" }, - }, - bindings: { - id: deterministicIds(), - complete: () => { - completions++; - return completions === 1 - ? { - kind: "tools", - text: "Read", - calls: [{ id: "read-1", name: "read", args: { path: "/workspace/README.md" } }], - } - : { kind: "answer", text: "Done" }; - }, - requestPermission: () => gate.promise, - tools: new Map([ - [ - "read", - defineTool({ - input: z.object({ path: z.string() }), - locations: ({ path }) => [{ path }], - run: () => { - ran = true; - return "contents"; - }, - }), - ], - ]), - }, - }); - try { - const turn = session.input("Review"); - await until(() => capture.records.some((r) => r.event === "permission.waiting")); - const waiting = capture.records.find((r) => r.event === "permission.waiting")!.fields; - expect(waiting).toMatchObject({ - toolName: "read", - locations: [{ path: "/workspace/README.md" }], - callId: "read-1", - }); - for (const key of ["sessionId", "turnId", "requestId", "toolCallId", "batchId"]) - expect(waiting[key]).toBeString(); - expect(ran).toBe(false); - gate.resolve({ outcome: { outcome: "selected", optionId: "reject-once" } }); - await turn.settled; - expect(capture.records.find((r) => r.event === "permission.decided")?.fields).toMatchObject({ - requestId: waiting.requestId, - decision: "reject_once", - durationMs: expect.any(Number), - }); - expect(capture.records.find((r) => r.event === "permission.decided")?.level).toBe("info"); - const refusal = capture.records.find((r) => r.event === "permission.refused"); - expect(refusal?.level).toBe("warning"); - expect(refusal?.fields).toMatchObject({ - sessionId: waiting.sessionId, - turnId: waiting.turnId, - childId: waiting.childId, - batchId: waiting.batchId, - toolCallId: waiting.toolCallId, - requestId: waiting.requestId, - operation: "tool_execution", - outcome: "blocked", - reasonCode: "permission_refused", - reason: expect.stringContaining("User refused permission"), - toolName: "read", - rawInput: { path: "/workspace/README.md" }, - locations: [{ path: "/workspace/README.md" }], - }); - const terminal = capture.records.find((r) => r.event === "turn.settled"); - expect(terminal?.level).toBe("info"); - expect(terminal?.fields).toMatchObject({ - operation: "agent_turn", - agentId: "a", - message: "Agent turn completed", - trigger: "model_settled", - outcome: "completed", - appendId: expect.any(String), - }); - expect(ran).toBe(false); - } finally { - await session.close(); - capture.close(); - } -}); - -test("storage diagnostics retain uncertainty and recovery evidence; exhausted turns explain the limit", async () => { - const capture = observeLogs(); - const options = testOptions({ - steps: 1, - persistence: lostAcknowledgement(createMemoryPersistence(), (records) => - records.some((raw) => JSON.parse(raw).body.kind === "event"), - ), - complete: () => ({ - kind: "tools", - text: "Read", - calls: [{ id: "c", name: "echo", args: { text: "ok" } }], - }), - }); - const session = await createSession(options); - try { - await session.input("work").settled; - expect( - capture.records.find( - (r) => r.event === "append.settled" && r.fields.outcome === "indeterminate", - )?.fields, - ).toMatchObject({ - reason: "Receipt lost", - appendId: expect.any(String), - durationMs: expect.any(Number), - }); - expect(capture.records.some((r) => r.event === "append.reconciling")).toBe(true); - expect(capture.records.find((r) => r.event === "turn.settled")?.fields).toMatchObject({ - outcome: "exhausted", - stepLimit: 1, - reason: expect.stringContaining("allowance exhausted"), - }); - expect(capture.records.find((r) => r.event === "turn.settled")?.level).toBe("info"); - expect(capture.records.some((r) => r.event === "tool.awaiting_release")).toBe(true); - expect(capture.records.some((r) => r.event === "tool.released")).toBe(true); - await expect(restoreSession(options, "00000000-0000-4000-8000-000000000099")).rejects.toThrow(); - expect(capture.records.find((r) => r.event === "session.restore_failed")?.fields).toMatchObject( - { - sessionId: "00000000-0000-4000-8000-000000000099", - error: { message: "Session not found" }, - }, - ); - } finally { - await session.close(); - capture.close(); - } -}); - -test("cancellation identifies the active completion and preserves its terminal outcome", async () => { - const capture = observeLogs(); - const pending = deferred(); - let signal: AbortSignal | undefined; - const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { agent: "a", agents: new Map([["a", { model: "m" }]]), steps: 3 }, - bindings: { - id: deterministicIds(), - complete: (_request, cancellation) => { - signal = cancellation; - cancellation.addEventListener( - "abort", - () => pending.reject(new DOMException("Completion aborted by user", "AbortError")), - { once: true }, - ); - return pending.promise; - }, - }, - }); - try { - const turn = session.input("wait"); - await until(() => signal !== undefined); - await session.fire({ type: "abort" }); - await turn.settled; - const cancellation = capture.records.find( - (r) => r.event === "child.cancellation_requested", - )!.fields; - const cancelledChildId = cancellation.childId; - expect(cancellation).toMatchObject({ - sessionId: expect.any(String), - childId: expect.any(String), - operation: "completion", - }); - expect( - capture.records.some( - (r) => - r.event === "child.settled" && - r.fields.childId === cancelledChildId && - r.fields.outcome === "cancelled", - ), - ).toBe(true); - expect(capture.records.find((r) => r.event === "turn.settled")?.fields.outcome).toBe("aborted"); - expect(capture.records.find((r) => r.event === "turn.settled")?.level).toBe("info"); - expect(signal?.aborted).toBe(true); - await until(() => capture.records.some((r) => r.event === "child.cancelled")); - expect( - capture.records.some( - (r) => r.event === "child.failed" && r.fields.childId === cancelledChildId, - ), - ).toBe(false); - expect(capture.records.find((r) => r.event === "child.cancelled")?.fields).toMatchObject({ - phase: "run", - error: { name: "AbortError", message: "Completion aborted by user" }, - }); - } finally { - await session.close(); - pending.resolve({ kind: "answer", text: "late" }); - capture.close(); - } -}); - -test("restore registry drift identifies changed fields and removed tools without logging prompt bodies", async () => { - const capture = observeLogs(); - const options = testOptions(); - const session = await createSession(options); - await session.close(); - const sessionId = session.snapshot.durable.conversation.sessionId; - try { - const drifted = testOptions({ - persistence: options.persistence, - agents: new Map([ - ["a", { model: "replacement", systemPrompt: "PRIVATE_REPLACEMENT_PROMPT", tools: [] }], - ]), - tools: new Map(), - }); - const restored = await restoreSession(drifted, sessionId); - const differences = [ - "changed agents.a.model", - "changed agents.a.systemPrompt", - "changed agents.a.tools", - "missing agents.b", - "missing tools.echo", - ]; - expect(restored.registry).toEqual({ kind: "pending_adoption", differences }); - const mismatch = capture.records.find((record) => record.event === "session.registry.mismatch"); - expect(mismatch).toMatchObject({ - level: "info", - fields: { sessionId, revision: 1, differences, adoption: "pending" }, - }); - const reconciled = capture.records.filter( - (record) => record.event === "session.registry.reconciled", - ); - expect(reconciled.map((record) => [record.level, record.fields.reconciliation])).toEqual([ - ["info", "tool_registry"], - ["info", "policy_tools_removed"], - ["info", "policy_agent_removed"], - ]); - expect(reconciled[0]!.fields).toMatchObject({ removedTools: ["echo"], changedTools: [] }); - expect(reconciled[1]!.fields).toMatchObject({ - agentId: "a", - removedTools: ["echo"], - policyVersion: 1, - }); - expect(reconciled[2]!.fields).toMatchObject({ agentId: "b", removedTools: ["echo"] }); - expect( - capture.records.find((record) => record.event === "session.restored")?.fields.registry, - ).toBe("pending_adoption"); - expect(capture.records.filter((record) => ["warning", "error"].includes(record.level))).toEqual( - [], - ); - expect(JSON.stringify(capture.records)).not.toContain("PRIVATE_REPLACEMENT_PROMPT"); - expect(JSON.stringify(capture.records)).not.toContain("Agent A"); - await restored.close(); - } finally { - capture.close(); - } -}); - -test("session logs expose full system instructions actually supplied before and after reconfiguration", async () => { - const { journalMarkdown } = await import("./session-log.ts"); - const capture = observeLogs(); - const prompt = - "Review experimental claims against measured evidence.\nReport uncertainty and cite the source files."; - const options = testOptions({ - agents: new Map([["reviewer", { model: "review-model", systemPrompt: prompt }]]), - agent: "reviewer", - systemInputs: ["Do not modify experiment data."], - }); - const session = await createSession(options); - try { - await session.input("Review the measurements").settled; - expect(await session.updateSystem(["Limit this review to calibration errors."])).toMatchObject({ - kind: "accepted", - }); - await session.input("Review the calibration").settled; - const instructions = capture.records.filter( - (record) => record.event === "completion.system_prompt", - ); - expect(instructions).toHaveLength(2); - expect(instructions[0]).toMatchObject({ - level: "debug", - fields: { - sessionId: session.snapshot.durable.conversation.sessionId, - agentId: "reviewer", - model: "review-model", - systemMessages: [ - { role: "system", content: prompt }, - { role: "system", content: "Do not modify experiment data." }, - ], - }, - }); - expect(instructions[1]!.fields.systemMessages).toEqual([ - { role: "system", content: prompt }, - { role: "system", content: "Limit this review to calibration errors." }, - ]); - expect(instructions[0]!.fields.turnId).toBeDefined(); - expect(instructions[0]!.fields.childId).toBeDefined(); - const restored = await restoreSession(options, session.snapshot.durable.conversation.sessionId); - try { - const text = journalMarkdown(restored.snapshot.durable); - expect(text).toContain( - prompt - .split("\n") - .map((line) => `> ${line}`) - .join("\n"), - ); - expect(text).toContain("Do not modify experiment data."); - expect(text).toContain("Limit this review to calibration errors."); - expect( - capture.records.filter((record) => record.event === "completion.system_prompt"), - ).toHaveLength(2); - expect( - capture.records.filter((record) => ["warning", "error"].includes(record.level)), - ).toEqual([]); - } finally { - await restored.close(); - } - } finally { - await session.close(); - capture.close(); - } -}); - -test("a session-level effects subscriber receives completion usage and a tool event with correlation IDs", async () => { - const events: EffectEvent[] = []; - let calls = 0; - const session = await createSession({ - persistence: createMemoryPersistence(), - configuration: { - agent: "a", - agents: new Map([["a", { model: "m", tools: ["echo"] }]]), - steps: 3, - }, - bindings: { - id: deterministicIds(), - effects: (event) => events.push(event), - complete: () => { - calls++; - if (calls === 1) - return { - completion: { - kind: "tools", - text: "work", - calls: [{ id: "c", name: "echo", args: { text: "ok" } }], - }, - usage: { status: "reported", inputTokens: 5, outputTokens: 2, native: {} }, - }; - return { completion: { kind: "answer", text: "done" } }; - }, - tools: new Map([ - ["echo", defineTool({ input: z.object({ text: z.string() }), run: ({ text }) => text })], - ]), - }, - }); - try { - await session.input("go").settled; - const sessionId = session.snapshot.durable.conversation.sessionId; - - const usageEvent = events.find((event) => event.type === "completion.usage.received"); - if (usageEvent?.type !== "completion.usage.received") throw new Error("Expected usage event"); - expect(usageEvent.sessionId).toBe(sessionId); - expect(usageEvent.turnId).toBeString(); - expect(usageEvent.childId).toBeString(); - expect(usageEvent.usage.status).toBe("reported"); - if (usageEvent.usage.status !== "reported") throw new Error("Expected reported usage"); - expect(usageEvent.usage.inputTokens).toBe(5); - expect(usageEvent.usage.outputTokens).toBe(2); - - const toolEvent = events.find((event) => event.type === "tool.admitted"); - if (toolEvent?.type !== "tool.admitted") throw new Error("Expected tool.admitted event"); - expect(toolEvent.sessionId).toBe(sessionId); - expect(toolEvent.turnId).toBeString(); - expect(toolEvent.callId).toBe("c"); - expect(toolEvent.toolName).toBe("echo"); - } finally { - await session.close(); - } -}); - -test("a tool's text over 256 KiB fails the call with a warning naming the tool, size and fix", async () => { - const capture = observeLogs(); - const session = await createSession( - testOptions({ - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: () => ({ - text: "see attachment", - parts: [ - { type: "text", text: "see attachment" }, - { - type: "blob", - bytes: new TextEncoder().encode("L".repeat(256 * 1024 + 1)), - media: "text/plain", - }, - ], - }), - }), - ], - ]), - complete: () => ({ - kind: "tools", - text: "", - calls: [{ id: "c1", name: "echo", args: { text: "x" } }], - }), - }), - ); - const sessionId = session.snapshot.durable.conversation.sessionId; - const message = - "echo returned 262145 bytes of text/plain, over the 256 KiB tool result limit; narrow the request"; - try { - session.input("Go"); - await until(() => session.snapshot.durable.conversation.log.length === 1); - const record = session.snapshot.durable.records.find((entry) => entry.body.kind === "tool"); - expect(record?.body).toMatchObject({ - kind: "tool", - callId: "c1", - result: { kind: "failed", error: { message: expect.stringContaining(message) } }, - }); - const failed = capture.records.filter( - (r) => r.event === "child.failed" && r.fields.phase === "validate_output", - ); - expect(failed).toHaveLength(1); - expect(failed[0]).toMatchObject({ - level: "warning", - fields: { - sessionId, - childId: expect.stringContaining("/c1"), - phase: "validate_output", - error: expect.objectContaining({ message: expect.stringContaining(message) }), - }, - }); - // The warning is diagnosable on its own: no payload bytes are logged. - expect(JSON.stringify(failed[0])).not.toContain("LLLL"); - } finally { - await session.close(); - capture.close(); - } -}); diff --git a/packages/core-agent/session/peer-review-acceptance.test.ts b/packages/core-agent/session/peer-review-acceptance.test.ts deleted file mode 100644 index e62ad58d..00000000 --- a/packages/core-agent/session/peer-review-acceptance.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { CompletionSchema } from "../agent/types.ts"; -import { createProviderCapture } from "../environment/provider-capture.ts"; -import { withFixtureDiagnostics } from "../logging/fixture-capture.ts"; -import { anthropicMessages, bindProviders, openaiChat } from "../providers/index.ts"; -import { - CompletionPortRequestSchema, - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./index.ts"; -import { deferred, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -for (const mode of [ - "refusal", - "interruption", - "timeout", - "malformed-json", - "cancellation", - "configuration", -] as const) { - test(`peer-review consumer handles ${mode}, restores without effects, and repeats only explicitly`, async () => { - const capture = await createProviderCapture(".session-artifacts/peer-review-acceptance"); - const counts = { coordinator: 0, extraction: 0, tool: 0, permission: 0 }; - const release = deferred(); - const requests: { provider: string; model: string; messages: number; bytes: number }[] = []; - let firstAttempt = true; - const extractor = bindProviders( - new Map([ - [ - "anthropic", - { - profile: anthropicMessages, - transport: { - baseUrl: "https://scripted.invalid/anthropic/v1", - capture: capture.capture, - fetch: (async (_url, init) => { - counts.extraction++; - const body = JSON.parse(String(init?.body)); - requests.push({ - provider: "anthropic", - model: body.model, - messages: body.messages.length, - bytes: new TextEncoder().encode(String(init?.body)).length, - }); - if (firstAttempt && ["interruption", "timeout", "cancellation"].includes(mode)) - await release.promise; - return Response.json({ - id: `extraction-${counts.extraction}`, - type: "message", - role: "assistant", - content: [ - { - type: "text", - text: - firstAttempt && mode === "malformed-json" - ? '{"claims":' - : '{"claims":["claim"]}', - }, - ], - stop_reason: "end_turn", - usage: { input_tokens: 10, output_tokens: 5 }, - }); - }) as typeof fetch, - }, - }, - ], - ]), - ); - const persistence = createMemoryPersistence(); - const options: SessionOptions = { - persistence, - configuration: { - agent: "reviewer", - agents: new Map([["reviewer", { model: "fast", tools: ["extract"] }]]), - steps: 5, - policy: { - provider: "coordinator", - model: "fast", - permissions: "ask", - ...(mode === "timeout" ? { toolTimeoutMs: 20 } : {}), - }, - }, - bindings: { - requestPermission: () => { - counts.permission++; - return { - outcome: { - outcome: "selected", - optionId: firstAttempt && mode === "refusal" ? "reject-once" : "allow-once", - }, - }; - }, - providers: new Map([ - [ - "coordinator", - { - profile: openaiChat, - models: new Map([ - ["fast", { wireModel: "wire-fast", profile: openaiChat }], - ["careful", { wireModel: "wire-careful", profile: openaiChat }], - ]), - transport: { - baseUrl: "https://scripted.invalid/coordinator/v1", - capture: capture.capture, - fetch: (async (_url, init) => { - counts.coordinator++; - const body = JSON.parse(String(init?.body)); - requests.push({ - provider: "coordinator", - model: body.model, - messages: body.messages.length, - bytes: new TextEncoder().encode(String(init?.body)).length, - }); - return Response.json({ - choices: [ - { - finish_reason: body.messages.at(-1).role === "tool" ? "stop" : "tool_calls", - message: - body.messages.at(-1).role === "tool" - ? { content: "Reviewed claim" } - : { - content: "Extracting claims", - tool_calls: [ - { - id: "extract-call", - type: "function", - function: { - name: "extract", - arguments: '{"text":"same paper"}', - }, - }, - ], - }, - }, - ], - }); - }) as typeof fetch, - }, - }, - ], - ]), - tools: new Map([ - [ - "extract", - defineTool({ - input: z.object({ text: z.string() }), - async run({ text }, signal, context) { - counts.tool++; - const result = await extractor.complete( - CompletionPortRequestSchema.parse({ - maxOutputTokens: 16384, - provider: "anthropic", - model: "extractor", - messages: [{ role: "user", content: text }], - }), - signal, - undefined, - undefined, - { ...context, childId: context?.toolCallId }, - ); - const completion = CompletionSchema.parse(result.completion); - if (completion.kind !== "answer") - throw new Error("Extraction must return an answer"); - return z.object({ claims: z.array(z.string()) }).parse(JSON.parse(completion.text)); - }, - }), - ], - ]), - }, - }; - await withFixtureDiagnostics( - capture.directory, - { runId: capture.runId, scenario: mode }, - async () => { - const session = await createSession(options); - let restored: Awaited> | undefined; - try { - const input = session.input("Review the paper"); - if (["interruption", "cancellation"].includes(mode)) { - await until(() => counts.extraction === 1); - if (mode === "interruption") await session.close(); - else await session.fire({ type: "abort" }); - } - const result = await input.settled; - if (mode === "interruption") expect(result.kind).toBe("closed"); - else if (mode === "cancellation") - expect(result).toMatchObject({ - kind: "terminal", - record: { outcome: { kind: "aborted", reason: { classification: "cancelled" } } }, - }); - else if (mode === "configuration") - expect(result).toMatchObject({ record: { outcome: { kind: "completed" } } }); - else if (mode === "refusal") - expect(result).toMatchObject({ record: { outcome: { kind: "completed" } } }); - else { - expect(result).toMatchObject({ - kind: "terminal", - record: { - outcome: { - kind: "failed", - error: { - classification: mode === "timeout" ? "timeout" : "execution", - operation: { toolName: "extract", callId: "extract-call" }, - }, - }, - }, - }); - } - if (mode === "refusal") - expect(counts).toEqual({ coordinator: 2, extraction: 0, tool: 0, permission: 1 }); - await session.close(); - const beforeRestore = { ...counts }; - restored = await restoreSession(options, session.snapshot.durable.conversation.sessionId); - expect(counts).toEqual(beforeRestore); - if (mode === "interruption") - expect(restored.snapshot.durable.conversation.log.at(-1)?.outcome).toMatchObject({ - kind: "failed", - error: { classification: "interrupted" }, - }); - release.resolve(); - firstAttempt = false; - expect( - await restored.updatePolicy({ - model: "careful", - toolTimeoutMs: null, - permissions: "off", - }), - ).toMatchObject({ kind: "accepted" }); - const repeat = await restored.input("Repeat the review explicitly").settled; - expect(repeat).toMatchObject({ record: { outcome: { kind: "completed" } } }); - expect(counts.tool).toBe(beforeRestore.tool + 1); - expect(counts.extraction).toBe(beforeRestore.extraction + 1); - expect(counts.permission).toBe(beforeRestore.permission); - expect( - requests - .filter((request) => request.provider === "coordinator") - .slice(-2) - .every((request) => request.model === "wire-careful"), - ).toBe(true); - const independent = requests.filter((request) => request.provider === "anthropic"); - expect(new Set(independent.map((request) => request.bytes)).size).toBe(1); - await Bun.write( - `${capture.directory}/outcomes.json`, - JSON.stringify({ first: result, repeat, beforeRestore, afterRepeat: counts }, null, 2), - ); - await Bun.write( - `${capture.directory}/acceptance.md`, - [ - `# Peer review: ${mode}`, - "", - "The initial outcome and explicit repeat are in [outcomes.json](outcomes.json). Restore made no completion, tool, or permission invocation.", - "", - "Configuration changed before the repeat: careful model, permissions off, no tool timeout. The repeat extracted once. Full traffic is in [README.md](README.md); saved execution is in [journal.json](journal.json).", - "", - "| Provider | Model | Message count | Request bytes |", - "| --- | --- | --- | --- |", - ...requests.map( - (request) => - `| ${request.provider} | ${request.model} | ${request.messages} | ${request.bytes} |`, - ), - "", - "Coordinator history and independent Anthropic requests are measured separately. No history storage change is applied.", - ].join("\n"), - ); - } finally { - const journal = await persistence.load( - session.snapshot.durable.conversation.sessionId, - new AbortController().signal, - ); - await Bun.write(`${capture.directory}/journal.json`, JSON.stringify(journal, null, 2)); - release.resolve(); - await session.close(); - await restored?.close(); - await capture.flush(); - } - }, - ); - const logs = (await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const warnings = logs.filter((log) => ["warning", "error"].includes(log.level)); - if (mode === "configuration") expect(warnings).toEqual([]); - if (mode === "refusal") - expect( - warnings.some((log) => log.event === "permission.refused" && log.toolName === "extract"), - ).toBe(true); - if (mode === "timeout") - expect(warnings.some((log) => log.event === "child.timed_out")).toBe(true); - if (mode === "malformed-json") - expect( - warnings.some( - (log) => log.event === "child.failed" && JSON.stringify(log).includes("JSON"), - ), - ).toBe(true); - }); -} diff --git a/packages/core-agent/session/permissions-runtime.test.ts b/packages/core-agent/session/permissions-runtime.test.ts deleted file mode 100644 index de28ad4f..00000000 --- a/packages/core-agent/session/permissions-runtime.test.ts +++ /dev/null @@ -1,761 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { createAgentRuntime } from "../agent/agent-runtime.ts"; -import type { HostToolNotification } from "../host/host.ts"; -import type { PermissionPort, PermissionRequest } from "../host/ports.ts"; -import { openaiChat } from "../providers/index.ts"; -import { journalJSONL, replay } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deferred, deterministicIds, lostAcknowledgement, until } from "./test-support.ts"; -import { createMemoryBacking, createMemoryPersistence } from "./testing/memory-persistence.ts"; -import { JournalRecordSchema } from "./types.ts"; - -const allow = { outcome: { outcome: "selected", optionId: "allow-once" } }; - -const reject = { outcome: { outcome: "selected", optionId: "reject-once" } }; - -const calls = { - kind: "tools", - text: "Read", - calls: [ - { id: "one", name: "echo", args: { path: "/tmp/one" } }, - { id: "two", name: "echo", args: { path: "/tmp/two" } }, - ], -}; - -function setup(permission: PermissionPort = () => allow) { - const backing = createMemoryBacking(); - const updates: HostToolNotification[] = []; - const requests: PermissionRequest[] = []; - const ran: string[] = []; - let parses = 0; - let completions = 0; - const options: SessionOptions = { - persistence: createMemoryPersistence(backing), - configuration: { - agent: "a", - agents: new Map([["a", { model: "m", tools: ["echo"] }]]), - steps: 4, - policy: { permissions: "ask" }, - }, - bindings: { - id: deterministicIds(), - complete: () => (++completions % 2 ? calls : { kind: "answer", text: "Done" }), - requestPermission: (request, signal) => { - requests.push(request); - return permission(request, signal); - }, - toolUpdate: (event) => { - updates.push(event); - }, - tools: new Map([ - [ - "echo", - defineTool({ - input: z - .object({ path: z.string() }) - .transform(({ path }) => ({ path: `${path}/${++parses}` })), - kind: "read", - locations: ({ path }) => [{ path }], - run: ({ path }) => { - ran.push(path); - return path; - }, - }), - ], - ]), - }, - }; - return { options, backing, updates, requests, ran, parses: () => parses }; -} - -test("intent and permission receipts gate prompts and the entire batch; parsed input is reused", async () => { - const second = deferred(); - const { options, requests, ran, updates, parses } = setup(() => - requests.length === 2 ? second.promise : allow, - ); - const base = options.persistence; - const intent = deferred(); - const approval = deferred(); - let waitingIntent = false; - let waitingApproval = false; - const session = await createSession({ - ...options, - persistence: { - ...base, - append: async (request, signal) => { - const records = request.records.map((raw) => JSON.parse(raw)); - if ( - records.some( - (r) => - r.body.event?.event?.type === "model_settled" && - r.body.event.event.result?.value?.kind === "tools", - ) - ) { - waitingIntent = true; - await intent.promise; - } - if (records.some((r) => r.body.event?.event?.type === "permission_settled")) { - waitingApproval = true; - await approval.promise; - } - return base.append(request, signal); - }, - }, - }); - const settled = session.input("Read").settled; - await until(() => waitingIntent); - expect(requests).toHaveLength(0); - expect(ran).toEqual([]); - intent.resolve(); - await until(() => requests.length === 2); - expect(ran).toEqual([]); - expect(session.snapshot.durable.conversation.turn.status).toBe("awaiting_permission"); - expect(Object.isFrozen(requests[0]?.toolCall)).toBe(true); - expect(requests[0]?.toolCall.locations).toEqual([{ path: "/tmp/one/1" }]); - second.resolve(allow); - await until(() => waitingApproval); - expect(ran).toEqual([]); - approval.resolve(); - const result = await settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(ran).toEqual(["/tmp/one/1", "/tmp/two/2"]); - expect(parses()).toBe(2); - for (const request of requests) { - const group = updates.filter((event) => event.toolCallId === request.toolCall.toolCallId); - expect(group.map((event) => event.status).filter(Boolean)).toEqual([ - "pending", - "in_progress", - "completed", - ]); - } - expect(session.snapshot.durable.records.every((record) => record.version === 2)).toBe(true); - expect(journalJSONL(session.snapshot.durable)).toContain('"decision":"allow_once"'); - expect(journalJSONL(session.snapshot.durable)).not.toContain('"locations"'); - const restored = await restoreSession(options, session.snapshot.durable.conversation.sessionId); - expect(requests).toHaveLength(2); - await Promise.all([session.close(), restored.close()]); -}); - -for (const [label, response, outcome] of [ - ["cancel", { outcome: { outcome: "cancelled" } }, "aborted"], - ["unknown option", { outcome: { outcome: "selected", optionId: "allow-always" } }, "failed"], - ["malformed response", {}, "failed"], - ["exception", new Error("port failed"), "failed"], -] as const) - test(`${label} on second permission prevents all tool execution`, async () => { - const { options, requests, ran, updates } = setup(() => { - if (requests.length === 1) return allow; - if (response instanceof Error) throw response; - return response; - }); - const session = await createSession(options); - const result = await session.input("Read").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe(outcome); - expect(ran).toEqual([]); - expect(updates.filter((event) => event.status === "failed")).toHaveLength(2); - expect(session.snapshot.durable.records.some((record) => record.body.kind === "tool")).toBe( - false, - ); - await session.close(); - }); - -test("reject on second permission runs the approved call, refuses only that call, and the turn continues", async () => { - const { options, requests, ran, updates } = setup(() => (requests.length === 1 ? allow : reject)); - const session = await createSession(options); - const result = await session.input("Read").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(ran).toEqual(["/tmp/one/1"]); - expect(updates.filter((event) => event.status === "failed")).toHaveLength(1); - const toolRecords = session.snapshot.durable.records.filter( - (record) => record.body.kind === "tool", - ); - expect(toolRecords).toHaveLength(2); - expect(JSON.stringify(toolRecords)).toContain('"classification":"permission_refused"'); - await session.close(); -}); - -test("abort signals permission port, rejects ordinary barge-in, and ignores late approval", async () => { - const pending = deferred(); - let signal: AbortSignal | undefined; - const { options, requests, ran } = setup((_, value) => { - signal = value; - return pending.promise; - }); - const session = await createSession(options); - const turn = session.input("Read"); - await until(() => requests.length === 1); - expect((await session.input("Wait").accepted).kind).toBe("failed"); - await session.fire({ type: "abort" }); - const result = await turn.settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("aborted"); - expect(signal?.aborted).toBe(true); - pending.resolve({ outcome: { outcome: "selected", optionId: "allow-session" } }); - await Bun.sleep(5); - expect(requests).toHaveLength(1); - expect(ran).toEqual([]); - await session.close(); -}); - -for (const approved of [false, true]) - test(`restore interrupted ${approved ? "approved" : "pending"} permission never reasks or runs`, async () => { - const response = deferred(); - const { options, requests, ran } = setup(() => (approved ? allow : response.promise)); - const base = options.persistence; - const delayed = deferred(); - let waiting = false; - const session = await createSession({ - ...options, - persistence: { - ...base, - append: async (request, signal) => { - const result = await base.append(request, signal); - if ( - approved && - request.records.some( - (raw) => JSON.parse(raw).body.event?.event?.type === "permission_settled", - ) - ) { - waiting = true; - await delayed.promise; - } - return result; - }, - }, - }); - const turn = session.input("Read"); - await until(() => (approved ? waiting : requests.length === 1)); - const id = session.snapshot.durable.conversation.sessionId; - await session.close(); - expect((await turn.settled).kind).toBe("closed"); - response.resolve(allow); - delayed.resolve(); - const count = requests.length; - const restored = await restoreSession(options, id); - expect(requests).toHaveLength(count); - expect(ran).toEqual([]); - expect(restored.snapshot.durable.conversation.log.at(-1)?.outcome.kind).toBe("failed"); - await restored.close(); - }); - -test("missing binding fails create/restore; journal version and owner decisions cannot be forged", async () => { - const { options, backing } = setup(); - await expect( - createSession({ ...options, bindings: { ...options.bindings, requestPermission: undefined } }), - ).rejects.toThrow("Missing permission"); - const session = await createSession(options); - await session.input("Read").settled; - const id = session.snapshot.durable.conversation.sessionId; - await expect( - restoreSession( - { ...options, bindings: { ...options.bindings, requestPermission: undefined } }, - id, - ), - ).rejects.toThrow("Missing permission"); - const batches = backing.get(id)!; - for (const mutate of [ - (r: any) => { - if (r.body.event?.event?.type === "permission_settled") - r.body.event.event.result.value[0].callId = "forged"; - }, - (r: any) => { - if (r.body.event?.event?.type === "permission_settled") r.body.event.event.result.value.pop(); - }, - ]) { - const changed = batches.map((batch) => ({ - ...batch, - records: batch.records.map((raw) => { - const record = JSON.parse(raw); - mutate(record); - return JSON.stringify(record); - }), - })); - // The forged record names a permission operation or decisions the folded turn cannot take. - expect(() => replay(changed)).toThrow("record_applicable"); - } - expect(() => - JournalRecordSchema.parse({ ...session.snapshot.durable.records[0], version: 5 }), - ).toThrow(); - await session.close(); -}); - -test("lost approval receipt reconciles once; forks require new approvals", async () => { - const { options, requests, ran } = setup(); - const session = await createSession({ - ...options, - persistence: lostAcknowledgement(options.persistence, (records) => - records.some((raw) => raw.includes('"permission_settled"')), - ), - }); - await session.input("Read").settled; - expect(ran).toHaveLength(2); - const child = await session.fork(); - await child.input("Again").settled; - expect(ran).toHaveLength(4); - expect(requests).toHaveLength(4); - expect(requests[2]?.sessionId).not.toBe(requests[0]?.sessionId); - await Promise.all([session.close(), child.close()]); -}); - -test("nonjournaled runtime uses the same permission phase and port", async () => { - const pending = deferred(); - const { options, requests, ran } = setup(() => pending.promise); - const runtime = createAgentRuntime({ - ...options.configuration, - ...options.bindings, - baseUrl: "https://example.invalid", - complete: () => calls, - }); - await runtime.fire({ type: "user", text: "Read" }); - await until(() => requests.length === 1); - expect(runtime.snapshot.conversation.turn.status).toBe("awaiting_permission"); - pending.resolve(reject); - await until(() => runtime.snapshot.conversation.log.length === 1); - expect(ran).toEqual([]); -}); - -for (const admission of ["queue-user", "abort-tools-on-user"] as const) - test(`${admission} applies while awaiting permission`, async () => { - const pending = deferred(); - const { options, requests, ran } = setup(() => pending.promise); - const session = await createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { permissions: "ask", admission, bargeIn: admission !== "queue-user" }, - }, - }); - const first = session.input("Read"); - await until(() => requests.length === 1); - const second = session.input("Next"); - expect((await second.accepted).kind).toBe("accepted"); - if (admission === "queue-user") { - expect(session.snapshot.durable.conversation.turn.status).toBe("awaiting_permission"); - pending.resolve({ outcome: { outcome: "cancelled" } }); - } - const firstResult = await first.settled; - expect(firstResult.kind === "terminal" && firstResult.record.outcome.kind).toBe("aborted"); - const secondResult = await second.settled; - expect(secondResult.kind === "terminal" && secondResult.record.outcome.kind).toBe("completed"); - expect(ran).toEqual([]); - pending.resolve(allow); - await session.close(); - }); - -test("rejected approval append never executes tools", async () => { - const { options, ran } = setup(); - const base = options.persistence; - const session = await createSession({ - ...options, - persistence: { - ...base, - append: (request, signal) => - request.records.some( - (raw) => JSON.parse(raw).body.event?.event?.type === "permission_settled", - ) - ? Promise.resolve({ kind: "rejected", message: "No approval receipt" }) - : base.append(request, signal), - }, - }); - expect((await session.input("Read").settled).kind).toBe("failed"); - expect(ran).toEqual([]); - await session.close(); -}); - -test("permission mode changes only at idle policy boundaries; off keeps existing execution", async () => { - const { options, requests, ran } = setup(); - const session = await createSession({ - ...options, - configuration: { ...options.configuration, policy: {} }, - }); - await session.input("Read").settled; - expect(requests).toHaveLength(0); - expect(ran).toHaveLength(2); - expect(session.snapshot.durable.records[0]?.version).toBe(2); - await session.updatePolicy({ permissions: "ask" }); - await session.input("Ask").settled; - expect(requests).toHaveLength(2); - expect(session.snapshot.durable.records.at(-1)?.version).toBe(2); - await session.updatePolicy({ permissions: "off" }); - await session.input("Off").settled; - expect(requests).toHaveLength(2); - expect(ran).toHaveLength(6); - const restored = await restoreSession(options, session.snapshot.durable.conversation.sessionId); - await Promise.all([session.close(), restored.close()]); -}); - -test("session tool approval is committed before reuse, retained across model changes, revoked when the permission mode changes, and absent on restore", async () => { - const { withFixtureDiagnostics } = await import("../logging/fixture-capture.ts"); - const directory = `.session-artifacts/permission-grants/${crypto.randomUUID()}`; - await withFixtureDiagnostics(directory, {}, async () => { - const setupResult = setup(() => ({ - outcome: { outcome: "selected", optionId: "allow-session" }, - })); - const { options: originalOptions, requests, ran, parses } = setupResult; - let httpCalls = 0; - const options: SessionOptions = { - ...originalOptions, - configuration: { - ...originalOptions.configuration, - policy: { permissions: "ask", provider: "scripted" }, - }, - bindings: { - ...originalOptions.bindings, - complete: undefined, - providers: new Map([ - [ - "scripted", - { - profile: openaiChat, - transport: { - baseUrl: "https://scripted.invalid/v1", - fetch: (async () => - Response.json({ - choices: [ - { - finish_reason: ++httpCalls % 2 ? "tool_calls" : "stop", - message: - httpCalls % 2 - ? { - content: calls.text, - tool_calls: calls.calls.map((call) => ({ - id: call.id, - type: "function", - function: { - name: call.name, - arguments: JSON.stringify(call.args), - }, - })), - } - : { content: "Done" }, - }, - ], - })) as unknown as typeof fetch, - }, - }, - ], - ]), - }, - }; - const base = options.persistence; - const receipt = deferred(); - let awaitingReceipt = false; - const resetReceipt = deferred(); - let awaitingReset = false; - const session = await createSession({ - ...options, - persistence: { - ...base, - async append(request, signal) { - if ( - !awaitingReceipt && - request.records.some( - (raw) => JSON.parse(raw).body.event?.event?.type === "permission_settled", - ) - ) { - awaitingReceipt = true; - await receipt.promise; - } - if ( - request.records.some((raw) => { - const body = JSON.parse(raw).body; - return body.kind === "policy" && body.policy.permissions === "off"; - }) - ) { - awaitingReset = true; - await resetReceipt.promise; - } - return base.append(request, signal); - }, - }, - }); - try { - const first = session.input("Read both files"); - await until(() => awaitingReceipt); - expect(requests).toHaveLength(1); - expect(ran).toHaveLength(0); - receipt.resolve(); - await first.settled; - await session.input("Read them again").settled; - expect(requests).toHaveLength(1); - expect(ran).toHaveLength(4); - expect(parses()).toBe(4); - expect(journalJSONL(session.snapshot.durable)).toContain('"source":"remembered"'); - for (const [patch, kind] of [ - [{ model: "another-model" }, "accepted"], - [{ thinking: "off" as const }, "accepted"], - [{ steps: 8, completionTimeoutMs: 1000, toolTimeoutMs: 1000 }, "accepted"], - [{ tools: { a: ["echo"] } }, "ignored"], - ] as const) { - expect((await session.updatePolicy(patch)).kind).toBe(kind); - expect(await session.input("Keep the existing tool approval").settled).toMatchObject({ - kind: "terminal", - record: { outcome: { kind: "completed" } }, - }); - expect(requests).toHaveLength(1); - } - const beforeReset = session.snapshot.durable.policy!.version; - const reset = session.updatePolicy({ permissions: "off" }); - await until(() => awaitingReset); - expect(session.snapshot.durable.policy!.version).toBe(beforeReset); - expect(await Bun.file(`${directory}/diagnostics.jsonl`).text()).not.toContain( - '"event":"permission.grants_cleared"', - ); - resetReceipt.resolve(); - expect((await reset).kind).toBe("accepted"); - expect((await session.updatePolicy({ permissions: "ask" })).kind).toBe("accepted"); - await session.input("Ask again after revocation").settled; - expect(requests).toHaveLength(2); - expect((await session.updatePolicy({ tools: { a: [] } })).kind).toBe("accepted"); - expect((await session.updatePolicy({ tools: { a: ["echo"] } })).kind).toBe("accepted"); - await session.input("Ask after removing and re-enabling the tool").settled; - expect(requests).toHaveLength(3); - const restored = await restoreSession( - options, - session.snapshot.durable.conversation.sessionId, - ); - try { - expect(requests).toHaveLength(3); - await restored.input("Ask again after reopening").settled; - expect(requests).toHaveLength(4); - } finally { - await restored.close(); - } - } finally { - receipt.resolve(); - resetReceipt.resolve(); - await session.close(); - } - }); - const logs = (await Bun.file(`${directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect( - logs.some( - (entry) => - entry.event === "permission.granted" && - entry.toolName === "echo" && - entry.scope === "live-session-tool", - ), - ).toBe(true); - expect( - logs.some( - (entry) => - entry.event === "permission.reused" && entry.grantId && entry.turnId && entry.toolCallId, - ), - ).toBe(true); - const resets = logs.filter((entry) => entry.event === "permission.grants_cleared"); - expect(resets).toHaveLength(4); - expect(resets[0].reason).toContain("Permission mode changed"); - expect(resets[0].toolNames).toEqual(["echo"]); - expect(resets[1].reason).toContain("Permission mode changed"); - expect(resets[2].reason).toContain("Allowed tool scope changed"); - for (const reset of resets) { - expect(reset.sessionId).toBeTruthy(); - expect(reset.policyVersion).toBeGreaterThan(0); - const persisted = logs.findIndex( - (entry) => - entry.event === "append.settled" && - entry.appendId === reset.appendId && - entry.outcome === "committed", - ); - expect(persisted).toBeGreaterThanOrEqual(0); - expect(persisted).toBeLessThan(logs.indexOf(reset)); - } - expect(logs.filter((entry) => ["warning", "error"].includes(entry.level))).toEqual([]); -}); - -test("a remembered grant survives a refused sibling in the same batch; the refused tool is asked again", async () => { - let round = 0; - const fixture = setup((request) => - request.toolCall.name === "other" - ? reject - : { outcome: { outcome: "selected", optionId: "allow-session" } }, - ); - const echo = fixture.options.bindings.tools!.get("echo")!; - const session = await createSession({ - ...fixture.options, - configuration: { - ...fixture.options.configuration, - agents: new Map([["a", { model: "m", tools: ["echo", "other"] }]]), - }, - bindings: { - ...fixture.options.bindings, - tools: new Map([ - ["echo", echo], - ["other", echo], - ]), - complete: () => { - round++; - return round % 2 - ? { ...calls, calls: [calls.calls[0], { ...calls.calls[1], name: "other" }] } - : { kind: "answer", text: "Done" }; - }, - }, - }); - try { - await session.input("First batch has a refused sibling").settled; - await session.input("New explicit invocation").settled; - expect(round).toBe(4); - expect(fixture.requests.map((request) => request.toolCall.name)).toEqual([ - "echo", - "other", - "other", - ]); - expect(fixture.ran).toEqual(["/tmp/one/1", "/tmp/one/3"]); - } finally { - await session.close(); - } -}); - -test("tolerant validation failures commit tool errors without permission or execution and restore without effects", async () => { - const { withFixtureDiagnostics } = await import("../logging/fixture-capture.ts"); - const directory = `.session-artifacts/tool-validation/${crypto.randomUUID()}`; - await withFixtureDiagnostics(directory, {}, async () => { - const f = setup(); - let completions = 0; - const permissionReceipt = deferred(); - let waitingForReceipt = false; - const options: SessionOptions = { - ...f.options, - persistence: { - ...f.options.persistence, - append: async (request, signal) => { - if ( - request.records.some( - (raw) => JSON.parse(raw).body.event?.event?.type === "permission_settled", - ) - ) { - waitingForReceipt = true; - await permissionReceipt.promise; - } - return f.options.persistence.append(request, signal); - }, - }, - configuration: { - ...f.options.configuration, - policy: { permissions: "ask", toolFailure: "return-error-and-continue" }, - }, - bindings: { - ...f.options.bindings, - complete: (request) => { - completions++; - if (completions === 1) - return { - kind: "tools", - text: "Read files", - calls: [ - { id: "invalid", name: "echo", args: { path: 42 } }, - { id: "valid", name: "echo", args: { path: "/tmp/valid" } }, - ], - }; - const results = request.messages.filter((message) => message.role === "tool"); - expect(results).toHaveLength(2); - const invalid = JSON.parse( - results.find((message) => message.tool_call_id === "invalid")!.content, - ); - expect(invalid).toEqual({ - error: "Invalid tool arguments", - issues: [{ path: ["path"], message: expect.stringContaining("expected string") }], - }); - return { - kind: "answer", - text: "The invalid call was rejected; the valid read completed.", - }; - }, - }, - }; - const session = await createSession(options); - const sessionId = session.snapshot.durable.conversation.sessionId; - try { - const turn = session.input("Read"); - await until(() => waitingForReceipt); - expect(completions).toBe(1); - expect(f.ran).toHaveLength(0); - permissionReceipt.resolve(); - expect(await turn.settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - expect(f.requests).toHaveLength(1); - expect(f.requests[0]!.toolCall.toolCallId).toEndWith("/valid"); - expect(f.ran).toEqual(["/tmp/valid/1"]); - expect(completions).toBe(2); - expect(journalJSONL(session.snapshot.durable)).toContain('"decision":"invalid_input"'); - } finally { - permissionReceipt.resolve(); - await session.close(); - } - const restored = await restoreSession(options, sessionId); - await restored.close(); - expect(f.requests).toHaveLength(1); - expect(f.ran).toHaveLength(1); - expect(completions).toBe(2); - }); - const records = (await Bun.file(`${directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const rejected = records.find((record) => record.event === "tool.input_rejected"); - expect(rejected).toMatchObject({ level: "warning", toolName: "echo", callId: "invalid" }); - expect(rejected.toolCallId).toEndWith("/invalid"); - expect(rejected.error.message).toContain("path"); - expect(rejected.consequence).toContain("will not execute or request approval"); -}); - -test("switching the permission mode away and back during a turn still revokes remembered approvals", async () => { - const { withFixtureDiagnostics } = await import("../logging/fixture-capture.ts"); - const directory = `.session-artifacts/permission-toggle/${crypto.randomUUID()}`; - const fixture = setup(() => ({ outcome: { outcome: "selected", optionId: "allow-session" } })); - const held = deferred(); - let completions = 0; - await withFixtureDiagnostics(directory, {}, async () => { - const session = await createSession({ - ...fixture.options, - bindings: { - ...fixture.options.bindings, - complete: () => { - const call = ++completions; - if (call === 3) return held.promise; - return call === 1 || call === 4 ? calls : { kind: "answer", text: "Done" }; - }, - }, - }); - try { - await session.input("Read both files").settled; - expect(fixture.requests).toHaveLength(1); - const second = session.input("Think"); - await until(() => completions === 3); - expect((await session.updatePolicy({ permissions: "off" })).kind).toBe("selected"); - expect((await session.updatePolicy({ permissions: "ask" })).kind).toBe("selected"); - held.resolve({ kind: "answer", text: "Done" }); - await second.settled; - await session.input("Read them again").settled; - expect(fixture.requests).toHaveLength(2); - expect( - session.snapshot.durable.records.filter((record) => record.body.kind === "policy"), - ).toEqual([]); - } finally { - held.resolve({ kind: "answer", text: "Done" }); - await session.close(); - } - }); - const logs = (await Bun.file(`${directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const unchanged = logs.find((entry) => entry.event === "configuration.unchanged"); - expect(unchanged).toMatchObject({ level: "info", revokesGrants: true }); - expect( - logs.find( - (entry) => - entry.event === "permission.grants_cleared" && entry.selectionId === unchanged.selectionId, - ), - ).toMatchObject({ toolNames: ["echo"] }); -}); diff --git a/packages/core-agent/session/persistence.test.ts b/packages/core-agent/session/persistence.test.ts deleted file mode 100644 index 9fca5f03..00000000 --- a/packages/core-agent/session/persistence.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { createSession } from "./session-runtime.ts"; -import { lostAcknowledgement, testOptions } from "./test-support.ts"; -import { createMemoryBacking, createMemoryPersistence } from "./testing/memory-persistence.ts"; -import { persistenceContract } from "./testing/persistence-contract.ts"; - -persistenceContract("process-local reference", () => { - const backing = createMemoryBacking(); - return { - writer: createMemoryPersistence(backing), - reader: () => createMemoryPersistence(backing), - }; -}); -test("creation reconciles a committed write with a lost acknowledgement", async () => { - const port = createMemoryPersistence(); - const session = await createSession(testOptions({ persistence: lostAcknowledgement(port) })); - expect(Number(session.snapshot.durable.revision)).toBe(1); -}); - -test("lost acknowledgements expose committed append IDs to independent readers", async () => { - const backing = createMemoryBacking(); - const writer = lostAcknowledgement(createMemoryPersistence(backing)); - const reader = createMemoryPersistence(backing); - const session = await createSession(testOptions({ persistence: writer })); - const loaded = await reader.load( - session.snapshot.durable.conversation.sessionId, - new AbortController().signal, - ); - expect(loaded.kind).toBe("loaded"); - if (loaded.kind !== "loaded") throw new Error("Expected committed stream"); - const batch = loaded.batches[0]!; - const { revision, ...request } = batch; - expect(await reader.append(request, new AbortController().signal)).toMatchObject({ - kind: "committed", - receipt: { revision }, - }); - expect(await reader.load(request.sessionId, new AbortController().signal)).toEqual(loaded); -}); diff --git a/packages/core-agent/session/persistence.ts b/packages/core-agent/session/persistence.ts deleted file mode 100644 index 173aba30..00000000 --- a/packages/core-agent/session/persistence.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { z } from "zod"; - -import type { BlobId, BlobMeta, MediaKind } from "../agent/content.ts"; -import { FailureSchema, SessionIdSchema } from "../agent/types.ts"; -import type { Policy } from "../policy/policy.ts"; - -export * from "../agent/content.ts"; - -/** Position in a session's journal stream: the number of committed records. 0 is an empty stream. */ -export const RevisionSchema = z.number().int().nonnegative().brand<"JournalRevision">(); -/** - * Stable identity of one append batch within a session. A retry reuses it, which lets the store - * recognize an identical retry and reject reuse with different content. - */ -export const AppendIdSchema = z.string().min(1).brand<"AppendId">(); -/** Journal position: the number of committed records ({@link RevisionSchema}). */ -export type Revision = z.infer; -/** Stable identity of one append batch ({@link AppendIdSchema}). */ -export type AppendId = z.infer; -/** Revision of an absent stream; the append that creates a session expects it. */ -export const INITIAL_REVISION = RevisionSchema.parse(0); -/** - * One journal append batch (not a tool batch), accepted or refused as a whole. `expectedRevision` - * is the stream revision the batch was staged against; `records` are serialized journal records. - */ -export const AppendRequestSchema = z - .strictObject({ - sessionId: SessionIdSchema, - expectedRevision: RevisionSchema, - appendId: AppendIdSchema, - records: z.array(z.string()).min(1).readonly(), - }) - .readonly(); -/** One atomic journal append batch ({@link AppendRequestSchema}). */ -export type AppendRequest = z.infer; -/** Proof that an append committed; `revision` is the stream revision after its batch. */ -export const ReceiptSchema = z - .strictObject({ - sessionId: SessionIdSchema, - appendId: AppendIdSchema, - revision: RevisionSchema, - }) - .readonly(); -/** Proof that an append committed ({@link ReceiptSchema}). */ -export type Receipt = z.infer; -/** A stored append batch: the request as appended plus the stream revision after it. */ -export const CommittedBatchSchema = AppendRequestSchema.unwrap() - .extend({ revision: RevisionSchema }) - .readonly(); -/** A stored append batch ({@link CommittedBatchSchema}). */ -export type CommittedBatch = z.infer; -/** - * Result of {@link SessionPersistence.load}: `loaded` is a consistent committed prefix, batches in - * append order and `revision` the revision after the last one; `not_found` is an absent stream; - * `failed` means the store could not answer and says nothing about what is committed. - */ -export const LoadResultSchema = z - .discriminatedUnion("kind", [ - z.strictObject({ - kind: z.literal("loaded"), - revision: RevisionSchema, - batches: z.array(CommittedBatchSchema).readonly(), - }), - z.strictObject({ kind: z.literal("not_found") }), - z.strictObject({ - kind: z.literal("failed"), - message: z.string(), - error: FailureSchema.optional(), - }), - ]) - .readonly(); -/** Result of a journal load ({@link LoadResultSchema}). */ -export type LoadResult = z.infer; -/** - * Result of {@link SessionPersistence.append}: - * - `committed`: the whole batch is stored; `receipt` proves it. - * - `conflict`: the stream is at `revision`, not the expected one; nothing was written. - * - `rejected`: refused without writing, for example an append ID reused with different content. - * - `indeterminate`: the outcome is unknown (lost acknowledgement, cancellation after dispatch). - * The session then reconciles with a load and may retry the same append once. - * - * A session fails on `conflict`, `rejected` or an indeterminate append it cannot reconcile. - */ -export const AppendResultSchema = z - .discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("committed"), receipt: ReceiptSchema }), - z.strictObject({ kind: z.literal("conflict"), revision: RevisionSchema }), - z.strictObject({ - kind: z.literal("rejected"), - message: z.string(), - error: FailureSchema.optional(), - }), - z.strictObject({ - kind: z.literal("indeterminate"), - message: z.string(), - error: FailureSchema.optional(), - }), - ]) - .readonly(); -/** Result of a journal append ({@link AppendResultSchema}). */ -export type AppendResult = z.infer; - -/** - * Journal, blob store and per-session configuration store a session writes to, supplied and owned - * by the caller. A conforming port loads a consistent committed prefix and appends an entire batch - * atomically. Revisions count records. Revision zero creates an absent stream; records are never - * overwritten. Append IDs are scoped to a session and retained for the advertised storage lifetime. - * An identical retry (including expectedRevision) returns its original receipt, even after - * subsequent writes. Reuse with different bytes/metadata is rejected before revision checking. - * Conflicts and rejected results certify no write by this request. A lost receipt or cancellation - * after dispatch must be indeterminate unless the adapter can prove a committed or uncommitted - * result. A load after append settlement must observe any committed write. Adapters must settle - * cancellation so reconciliation is possible; they must not commit later after reporting an - * uncommitted/indeterminate result and completing a subsequent consistent load. Thrown append - * errors are treated as indeterminate. - * No close operation: storage lifetime and ownership belong to the caller. - */ -export interface SessionPersistence { - /** Readable description of how long records, append IDs and blobs are retained. */ - readonly lifetime: string; - /** - * Stores immutable, session-scoped bytes under their SHA-256 `BlobId` and resolves with their - * metadata. Identical bytes return the same ID; more than 8 MiB is rejected before writing. Does - * not advance the journal revision. - */ - putBlob( - sessionId: z.infer, - bytes: Uint8Array, - meta: { media: MediaKind; name?: string }, - signal: AbortSignal, - ): Promise; - /** Reads a stored blob of this session; `{ kind: "not_found" }` when it is absent. */ - getBlob( - sessionId: z.infer, - id: BlobId, - signal: AbortSignal, - ): Promise<{ meta: BlobMeta; bytes: Uint8Array } | { kind: "not_found" }>; - /** Reads every committed batch of the session's journal; see {@link LoadResultSchema}. */ - load(sessionId: z.infer, signal: AbortSignal): Promise; - /** - * Commits one batch atomically if the stream is at `request.expectedRevision`; see - * {@link AppendResultSchema}. A thrown error counts as `indeterminate`. - */ - append(request: AppendRequest, signal: AbortSignal): Promise; - /** - * Reads the session's selected configuration: the latest value stored by `putConfig`, applied or - * not, or `undefined` when nothing was ever selected. Restore applies it when it differs from the - * journaled policy. Does not advance the journal revision and is never itself journaled. - */ - getConfig( - sessionId: z.infer, - signal: AbortSignal, - ): Promise; - /** - * Stores the session's selected configuration, replacing the previous one, independent of - * journal boundaries and of any turn in progress; later reads observe it. Applying a selection - * (a "configuration applied" journal record) is a separate step the runtime takes at the next - * boundary between turns. Rejects without writing when `signal` is already aborted. - */ - putConfig( - sessionId: z.infer, - policy: Policy, - signal: AbortSignal, - ): Promise; -} diff --git a/packages/core-agent/session/policy-runtime.test.ts b/packages/core-agent/session/policy-runtime.test.ts deleted file mode 100644 index 6fa34ca2..00000000 --- a/packages/core-agent/session/policy-runtime.test.ts +++ /dev/null @@ -1,831 +0,0 @@ -import { getLogger } from "@logtape/logtape"; -import { expect, spyOn, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { builtinResolvers } from "../policy/policy.ts"; -import { journalJSONL } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deferred, testOptions, until } from "./test-support.ts"; - -export function boundOptions(): SessionOptions { - return testOptions(); -} -test("v2 policy changes allowance and tools only after idle commit, restores and forks", async () => { - const options = boundOptions(); - const requests: any[] = []; - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - complete: (request) => { - requests.push(request); - return { kind: "answer", text: "ok" }; - }, - }, - }); - expect(session.snapshot.durable.records[0]?.version).toBe(2); - const before = session.snapshot; - expect((await session.updatePolicy({ steps: 1, tools: { a: [] } })).kind).toBe("accepted"); - const turn = session.input("Go"); - expect((await turn.settled).kind).toBe("terminal"); - expect(requests[0].tools).toEqual([]); - expect(Number(session.snapshot.durable.policy?.version)).toBe(1); - expect(Number(before.durable.policy?.version)).toBe(0); - const restored = await restoreSession(options, session.snapshot.durable.conversation.sessionId); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); - const child = await session.compact([]); - expect(child.snapshot.durable.policy).toEqual(session.snapshot.durable.policy); - expect((await session.updatePolicy({ tools: { a: ["undeclared"] } })).kind).toBe("failed"); -}); -test("policy changes append without rewriting prior records", async () => { - const options = testOptions(); - const session = await createSession(options); - await session.input("legacy").settled; - const bytes = journalJSONL(session.snapshot.durable); - await session.updatePolicy({ steps: 2 }); - expect(journalJSONL(session.snapshot.durable).startsWith(bytes)).toBe(true); - expect(session.snapshot.durable.records.slice(-1).map((record) => record.body.kind)).toEqual([ - "policy", - ]); - await session.input("new").settled; - expect( - (await restoreSession(options, session.snapshot.durable.conversation.sessionId)).snapshot - .durable, - ).toEqual(session.snapshot.durable); -}); -test("queue-user persists admission and settles each input with its own turn", async () => { - const options = boundOptions(); - const first = deferred(); - let calls = 0; - const session = await createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { admission: "queue-user", bargeIn: false }, - }, - bindings: { - ...options.bindings, - complete: () => (++calls === 1 ? first.promise : { kind: "answer", text: "second" }), - }, - }); - const a = session.input("first"); - await until(() => calls === 1); - const b = session.input("second"); - expect((await b.accepted).kind).toBe("accepted"); - expect(session.snapshot.durable.pendingInputs).toHaveLength(1); - expect((await session.updatePolicy({ steps: 7 })).kind).toBe("selected"); - first.resolve({ kind: "answer", text: "first" }); - const one = await a.settled; - const two = await b.settled; - expect(one.kind === "terminal" && one.turnId).not.toBe(two.kind === "terminal" && two.turnId); - expect( - session.snapshot.durable.conversation.log.map((record) => record.messages[0]?.text), - ).toEqual(["first", "second"]); - expect(session.snapshot.durable.pendingInputs).toHaveLength(0); -}); -test("recovery cancels durably queued inputs without starting them", async () => { - const options = boundOptions(); - const first = deferred(); - let calls = 0; - const session = await createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { admission: "queue-user", bargeIn: false }, - }, - bindings: { - ...options.bindings, - complete: () => { - calls++; - return first.promise; - }, - }, - }); - session.input("first"); - await until(() => calls === 1); - const queued = session.input("second"); - await queued.accepted; - await session.close(); - const restored = await restoreSession(options, session.snapshot.durable.conversation.sessionId); - expect(restored.snapshot.durable.records.at(-1)?.body.kind).toBe("input_cancelled"); - expect(restored.snapshot.durable.pendingInputs).toHaveLength(0); - expect(calls).toBe(1); - expect((await queued.settled).kind).toBe("closed"); -}); -test("abort-tools-on-user settles the interrupted turn before starting its successor", async () => { - const options = boundOptions(); - const slow = deferred(); - let started = false; - let calls = 0; - const session = await createSession({ - ...options, - configuration: { ...options.configuration, policy: { admission: "abort-tools-on-user" } }, - bindings: { - ...options.bindings, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: () => { - started = true; - return slow.promise; - }, - }), - ], - ]), - complete: () => - ++calls === 1 - ? { - kind: "tools", - text: "work", - calls: [{ id: "x", name: "echo", args: { text: "slow" } }], - } - : { kind: "answer", text: "new" }, - }, - }); - const first = session.input("first"); - await until(() => started); - const second = session.input("second"); - await second.accepted; - expect(await first.settled).toMatchObject({ record: { outcome: { kind: "aborted" } } }); - expect(await second.settled).toMatchObject({ record: { outcome: { kind: "completed" } } }); - slow.resolve("late"); -}); -test("tool failure continuation preserves raw evidence and deterministic correlated error", async () => { - const options = boundOptions(); - let calls = 0; - const session = await createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { toolFailure: "return-error-and-continue" }, - }, - bindings: { - ...options.bindings, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => { - if (text === "bad") throw new Error("broken"); - return text; - }, - }), - ], - ]), - complete: () => - ++calls === 1 - ? { - kind: "tools", - text: "work", - calls: [ - { id: "a", name: "echo", args: { text: "bad" } }, - { id: "b", name: "echo", args: { text: "ok" } }, - ], - } - : { kind: "answer", text: "done" }, - }, - }); - expect(await session.input("Go").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - expect( - session.snapshot.durable.records.some( - (record) => record.body.kind === "tool" && record.body.result.kind === "failed", - ), - ).toBe(true); - expect( - session.snapshot.durable.conversation.log[0]?.messages - .filter((message) => message.role === "tool") - .map((message) => message.text), - ).toEqual([expect.stringContaining('"error":"broken"'), "ok"]); - const failedMessage = session.snapshot.durable.conversation.log[0]!.messages.find( - (message) => message.role === "tool", - )!; - expect(JSON.parse(failedMessage.text)).toEqual({ error: "broken" }); - expect( - session.snapshot.durable.records.find( - (record) => record.body.kind === "tool" && record.body.result.kind === "failed", - )?.body, - ).toMatchObject({ - result: { - kind: "failed", - error: { message: "broken", operation: { kind: "tool", toolName: "echo" } }, - }, - }); - expect( - (await restoreSession(options, session.snapshot.durable.conversation.sessionId)).snapshot - .durable, - ).toEqual(session.snapshot.durable); -}); -test("a validation error thrown while a tool runs is not reported to the model as invalid arguments", async () => { - const options = boundOptions(); - let calls = 0; - const session = await createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { toolFailure: "return-error-and-continue" }, - }, - bindings: { - ...options.bindings, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - // The arguments are valid; the data the tool reads is not. - run: ({ text }) => String(z.number().parse(text)), - }), - ], - ]), - complete: () => - ++calls === 1 - ? { - kind: "tools", - text: "work", - calls: [{ id: "a", name: "echo", args: { text: "x" } }], - } - : { kind: "answer", text: "done" }, - }, - }); - expect(await session.input("Go").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - const failed = session.snapshot.durable.conversation.log[0]!.messages.find( - (message) => message.role === "tool", - )!; - const returned = JSON.parse(failed.text); - expect(returned).toEqual({ error: expect.stringContaining("expected number") }); - expect( - session.snapshot.durable.records.find( - (record) => record.body.kind === "tool" && record.body.result.kind === "failed", - )?.body, - ).toMatchObject({ result: { kind: "failed", error: { classification: "execution" } } }); -}); -test("restore reconciles a projection the live environment lacks; observer errors cannot fail a session", async () => { - const options = boundOptions(); - const seen: string[] = []; - const policies = { - projections: new Map([ - ...builtinResolvers.projections, - ["custom@1", () => [{ role: "user" as const, content: "custom" }]], - ]), - handoffs: builtinResolvers.handoffs, - }; - const configured = { - ...options, - configuration: { ...options.configuration, policy: { project: "custom@1" } }, - bindings: { - ...options.bindings, - policies, - observe: (snapshot: any) => { - seen.push(snapshot.status); - throw new Error("observer only"); - }, - }, - }; - const session = await createSession(configured); - await session.input("Go").settled; - expect(seen).toContain("committing"); - const sessionId = session.snapshot.durable.conversation.sessionId; - const same = await restoreSession(configured, sessionId); - expect(same.snapshot.durable).toEqual(session.snapshot.durable); - expect(same.registry).toEqual({ kind: "current" }); - await same.close(); - const prompts: unknown[] = []; - const live: SessionOptions = { - ...options, - bindings: { - ...options.bindings, - complete: (request) => { - prompts.push(request.messages); - return { kind: "answer", text: "ok" }; - }, - }, - }; - const logger = getLogger(["labkit", "session"]); - const emit = logger.emit.bind(logger); - const reconciled: { level: string; fields: Record }[] = []; - const spy = spyOn(logger, "emit").mockImplementation((record) => { - if (record.rawMessage === "session.registry.reconciled") - reconciled.push({ level: record.level, fields: record.properties }); - emit(record); - }); - const restored = await restoreSession(live, sessionId).finally(() => spy.mockRestore()); - expect(restored.registry).toEqual({ - kind: "pending_adoption", - differences: ["changed policy.project"], - }); - expect(restored.policy?.project).toBe("history@1"); - expect(reconciled).toEqual([ - { - level: "warning", - fields: expect.objectContaining({ - reconciliation: "policy", - changedFields: ["project"], - previous: { project: "custom@1" }, - next: { project: "history@1" }, - consequence: "next turn projects history with history@1", - }), - }, - ]); - const next = await restored.input("Next").settled; - expect(next.kind === "terminal" && next.record.outcome.kind).toBe("completed"); - expect(restored.registry).toEqual({ kind: "current" }); - expect( - restored.snapshot.durable.records.find((record) => record.body.kind === "configuration")?.body, - ).toMatchObject({ policy: { project: "history@1" } }); - expect(JSON.stringify(prompts.at(-1))).toContain("Go"); - expect(JSON.stringify(prompts.at(-1))).not.toContain("custom"); - await Promise.all([session.close(), restored.close()]); -}); - -test("rejected and uncertain policy appends preserve commit gating", async () => { - const options = boundOptions(); - const base = options.persistence; - const release = deferred(); - let writing = false; - const session = await createSession({ - ...options, - persistence: { - ...base, - async append(request, signal) { - const result = await base.append(request, signal); - if (request.records.some((record) => JSON.parse(record).body.kind === "policy")) { - writing = true; - await release.promise; - return { kind: "indeterminate", message: "lost" }; - } - return result; - }, - }, - }); - const before = session.snapshot.durable; - const update = session.updatePolicy({ steps: 0 }); - await until(() => writing); - const turn = session.input("Go"); - expect(session.snapshot.durable).toBe(before); - release.resolve(); - expect((await update).kind).toBe("accepted"); - expect(await turn.settled).toMatchObject({ record: { outcome: { kind: "exhausted" } } }); - expect( - session.snapshot.durable.records.filter((record) => record.body.kind === "policy"), - ).toHaveLength(1); - const rejectedOptions = boundOptions(); - const rejected = await createSession({ - ...rejectedOptions, - persistence: { - ...rejectedOptions.persistence, - append: (request, signal) => - request.records.some((record) => JSON.parse(record).body.kind === "policy") - ? Promise.resolve({ kind: "rejected", message: "offline" }) - : rejectedOptions.persistence.append(request, signal), - }, - }); - expect((await rejected.updatePolicy({ steps: 0 })).kind).toBe("failed"); - expect(Number(rejected.snapshot.durable.policy?.steps)).toBe(4); -}); - -test("context-only omits logged history but retains context and current input", async () => { - const options = boundOptions(); - const requests: any[] = []; - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - complete: (request) => { - requests.push(request); - return { kind: "answer", text: "ok" }; - }, - }, - }); - await session.input("old history").settled; - await session.updatePolicy({ project: "context-only@1" }); - await session.input("current").settled; - expect(requests[1].messages.map((message: any) => message.content)).toEqual([ - "Agent A", - "current", - ]); - const child = await session.compact([{ role: "user", text: "replacement" }]); - await child.input("new current").settled; - expect(requests[2].messages.map((message: any) => message.content)).toEqual([ - "Agent A", - "replacement", - "new current", - ]); -}); - -test("bound callbacks cannot be replaced by mutating the caller's bindings", async () => { - const options = boundOptions(); - const mutable = { ...options.bindings }; - const session = await createSession({ ...options, bindings: mutable }); - mutable.complete = () => { - throw new Error("replacement must not run"); - }; - expect(await session.input("Go").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); -}); - -test("observer reentrancy enters the mailbox and sees frozen snapshots", async () => { - const options = boundOptions(); - let session: Awaited> | undefined; - let changed = false; - const seen: number[] = []; - const configured = { - ...options, - bindings: { - ...options.bindings, - observe(snapshot: any) { - expect(Object.isFrozen(snapshot)).toBe(true); - seen.push(snapshot.durable.revision); - if ( - session && - !changed && - snapshot.durable.conversation.log.length === 1 && - snapshot.status === "ready" - ) { - changed = true; - void session.updateSystem(["from observer"]); - } - }, - }, - }; - session = await createSession(configured); - await session.input("Go").settled; - await until(() => session!.snapshot.durable.systemInputs.length === 1); - expect(seen).toEqual([...seen].sort((a, b) => a - b)); -}); - -function configurationEvents() { - const logger = getLogger(["labkit", "session"]); - const emit = logger.emit.bind(logger); - const events: { event: string; level: string; fields: Record }[] = []; - const spy = spyOn(logger, "emit").mockImplementation((record) => { - if (String(record.rawMessage).startsWith("configuration.")) - events.push({ - event: String(record.rawMessage), - level: record.level, - fields: record.properties, - }); - emit(record); - }); - return { events, restore: () => spy.mockRestore() }; -} - -test("a configuration selected while its turn is admitted applies at the next boundary; the running turn keeps its settings", async () => { - const options = boundOptions(); - const admitted = deferred(); - const answer = deferred(); - let admitting = false; - const offered: string[][] = []; - const log = configurationEvents(); - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - complete: (request) => { - offered.push(request.tools?.map((tool) => tool.function.name) ?? []); - return offered.length === 1 ? answer.promise : { kind: "answer", text: "second" }; - }, - }, - persistence: { - ...options.persistence, - async append(request, signal) { - const result = await options.persistence.append(request, signal); - if (request.records.some((record) => JSON.parse(record).body.event?.type === "user")) { - admitting = true; - await admitted.promise; - } - return result; - }, - }, - }); - try { - const sessionId = session.snapshot.durable.conversation.sessionId; - const first = session.input("First"); - await until(() => admitting); - const selected = await session.updatePolicy({ tools: { a: [] } }); - expect(selected).toMatchObject({ kind: "selected", policy: { tools: { a: [] } } }); - expect(await options.persistence.getConfig(sessionId, new AbortController().signal)).toEqual( - session.selectedPolicy, - ); - expect(session.selectedPolicy?.tools.a).toHaveLength(0); - expect(session.policy?.tools.a).toHaveLength(1); - admitted.resolve(); - await until(() => offered.length === 1); - expect(offered[0]).toEqual(["echo"]); - answer.resolve({ kind: "answer", text: "first" }); - const settled = await first.settled; - if (settled.kind !== "terminal") throw new Error("Expected the first turn to settle"); - await until(() => log.events.some((entry) => entry.event === "configuration.applied")); - const kinds = session.snapshot.durable.records.map((record) => record.body.kind); - expect(kinds.slice(kinds.lastIndexOf("terminal"))).toEqual(["terminal", "policy"]); - expect(session.policy?.tools.a).toHaveLength(0); - const second = await session.input("Second").settled; - if (second.kind !== "terminal") throw new Error("Expected the second turn to settle"); - expect(offered).toEqual([["echo"], []]); - const policyRecord = session.snapshot.durable.records.find( - (record) => record.body.kind === "policy", - ); - expect(log.events.map((entry) => [entry.level, entry.event])).toEqual([ - ["info", "configuration.selected"], - ["info", "configuration.applied"], - ]); - expect(log.events[0]!.fields).toMatchObject({ - sessionId, - version: 1, - inForceVersion: 0, - changedFields: ["tools"], - turnStatus: "awaiting_model", - source: "caller", - }); - expect(log.events[1]!.fields).toMatchObject({ - sessionId, - turnId: second.turnId, - selectionId: log.events[0]!.fields.selectionId, - version: 1, - revision: policyRecord?.revision, - appendId: policyRecord?.appendId, - changedFields: ["tools"], - }); - } finally { - log.restore(); - await session.close(); - } -}); - -test("a selection pending when the session stopped survives restart and applies at the recovered boundary", async () => { - const options = boundOptions(); - const offered: string[][] = []; - const stalled = deferred(); - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - complete: (request) => { - offered.push(request.tools?.map((tool) => tool.function.name) ?? []); - return stalled.promise; - }, - }, - }); - const sessionId = session.snapshot.durable.conversation.sessionId; - void session.input("Interrupted"); - await until(() => offered.length === 1); - expect((await session.updatePolicy({ tools: { a: [] } })).kind).toBe("selected"); - await session.close(); - expect(session.snapshot.durable.policy?.tools.a).toHaveLength(1); - const log = configurationEvents(); - const restored = await restoreSession( - { - ...options, - bindings: { - ...options.bindings, - complete: (request) => { - offered.push(request.tools?.map((tool) => tool.function.name) ?? []); - return { kind: "answer", text: "resumed" }; - }, - }, - }, - sessionId, - ); - try { - const kinds = restored.snapshot.durable.records.map((record) => record.body.kind); - expect(kinds.slice(-3)).toEqual(["recovery", "terminal", "policy"]); - expect(restored.policy?.tools.a).toHaveLength(0); - expect(restored.selectedPolicy).toEqual(restored.policy); - expect(log.events.map((entry) => entry.event)).toEqual([ - "configuration.selected", - "configuration.applied", - ]); - expect(log.events[0]!.fields).toMatchObject({ sessionId, source: "restore" }); - expect(log.events[1]!.fields).toMatchObject({ sessionId, changedFields: ["tools"] }); - await restored.input("After restart").settled; - expect(offered).toEqual([["echo"], []]); - } finally { - log.restore(); - await restored.close(); - } -}); - -test("input sent right after updatePolicy, without awaiting it, runs under the selection", async () => { - const options = boundOptions(); - const offered: string[][] = []; - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - complete: (request) => { - offered.push(request.tools?.map((tool) => tool.function.name) ?? []); - return { kind: "answer", text: "ok" }; - }, - }, - }); - try { - const selecting = session.updatePolicy({ tools: { a: [] } }); - const turn = session.input("Go"); - expect((await selecting).kind).toBe("accepted"); - await turn.settled; - expect(offered).toEqual([[]]); - expect(session.snapshot.durable.records.map((record) => record.body.kind).slice(0, 3)).toEqual([ - "created", - "policy", - "event", - ]); - } finally { - await session.close(); - } -}); - -test("a closed session answers a selection without storing it, so restore does not apply it", async () => { - const options = boundOptions(); - const session = await createSession(options); - const sessionId = session.snapshot.durable.conversation.sessionId; - await session.close(); - expect((await session.updatePolicy({ tools: { a: [] } })).kind).toBe("closed"); - expect( - await options.persistence.getConfig(sessionId, new AbortController().signal), - ).toBeUndefined(); - const restored = await restoreSession(options, sessionId); - try { - expect(restored.snapshot.durable.records.map((record) => record.body.kind)).toEqual([ - "created", - ]); - expect(restored.policy?.tools.a).toHaveLength(1); - } finally { - await restored.close(); - } -}); - -test("restore binds historical resolvers without requiring unrelated creation defaults", async () => { - const options = boundOptions(); - const policies = { - handoffs: builtinResolvers.handoffs, - projections: new Map([["only@1", () => [{ role: "user" as const, content: "fixed" }]]]), - }; - const session = await createSession({ - ...options, - configuration: { ...options.configuration, policy: { project: "only@1" } }, - bindings: { ...options.bindings, policies }, - }); - await session.input("Go").settled; - const restored = await restoreSession( - { ...options, bindings: { ...options.bindings, policies } }, - session.snapshot.durable.conversation.sessionId, - ); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); -}); - -class NonJsonToolOutput { - value = "not a plain object"; -} -for (const [name, value] of [ - ["undefined", undefined], - ["Date", new Date(0)], - ["class instance", new NonJsonToolOutput()], -] as const) { - test(`non-JSON ${name} tool output is a correlated failure and honors continuation`, async () => { - const options = boundOptions(); - let completions = 0; - const session = await createSession({ - ...options, - configuration: { ...options.configuration, policy: { id: "tolerant@1" } }, - bindings: { - ...options.bindings, - tools: new Map([ - ["echo", defineTool({ input: z.object({ text: z.string() }), run: () => value })], - ]), - complete: () => - ++completions === 1 - ? { - kind: "tools", - text: "run", - calls: [{ id: "call", name: "echo", args: { text: "go" } }], - } - : { kind: "answer", text: "continued" }, - }, - }); - expect(await session.input("go").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - const tool = session.snapshot.durable.records.find( - (record) => record.body.kind === "tool", - )?.body; - expect(tool).toMatchObject({ kind: "tool", callId: "call", result: { kind: "failed" } }); - if (tool?.kind !== "tool" || tool.result.kind !== "failed") - throw new Error("Expected tool failure"); - expect(tool.result.error.message).toContain("Tool output must be a JSON value"); - expect( - session.snapshot.durable.conversation.log[0]!.messages.find( - (message) => message.role === "tool", - )?.text, - ).toContain("Tool output must be a JSON value"); - await session.close(); - }); -} - -test("malformed projected arguments fail the step without inventing tool results", async () => { - const options = boundOptions(); - let completions = 0; - const policies = { - ...builtinResolvers, - projections: new Map([ - ...builtinResolvers.projections, - [ - "malformed@1", - () => [ - { - role: "assistant" as const, - content: "bad", - tool_calls: [ - { - id: "bad-call", - type: "function" as const, - function: { name: "echo", arguments: "{" }, - }, - ], - }, - { role: "tool" as const, content: "result", tool_call_id: "bad-call" }, - ], - ], - ]), - }; - const session = await createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { id: "tolerant@1", project: "malformed@1" }, - }, - bindings: { - ...options.bindings, - policies, - complete: () => { - completions++; - return { kind: "answer", text: "unexpected" }; - }, - }, - }); - expect(await session.input("go").settled).toMatchObject({ - record: { - outcome: { - kind: "failed", - error: { message: "Invalid JSON arguments for projected tool call bad-call" }, - }, - }, - }); - expect(completions).toBe(0); - expect(session.snapshot.durable.records.some((record) => record.body.kind === "tool")).toBe( - false, - ); - await session.close(); -}); - -test("malformed provider arguments fail completion even under tool-error continuation", async () => { - let tools = 0; - const options = testOptions({ - complete: undefined, - fetch: (async (_url: Parameters[0], _init?: RequestInit) => - Response.json({ - choices: [ - { - message: { - content: "bad", - tool_calls: [ - { id: "bad", type: "function", function: { name: "echo", arguments: "{" } }, - ], - }, - }, - ], - })) as typeof fetch, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: () => { - tools++; - return "unexpected"; - }, - }), - ], - ]), - }); - const session = await createSession(options); - await session.updatePolicy({ id: "tolerant@1" }); - expect(await session.input("go").settled).toMatchObject({ - record: { outcome: { kind: "failed" } }, - }); - expect(tools).toBe(0); - expect(session.snapshot.durable.records.some((record) => record.body.kind === "tool")).toBe( - false, - ); - await session.close(); -}); diff --git a/packages/core-agent/session/provider-runtime.test.ts b/packages/core-agent/session/provider-runtime.test.ts deleted file mode 100644 index f158d266..00000000 --- a/packages/core-agent/session/provider-runtime.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { - anthropicMessages, - googleGenerate, - openaiChat, - openaiResponses, - type CompletionProfile, -} from "../providers/index.ts"; -import type { SessionPersistence } from "./persistence.ts"; -import { journalJSONL, replay } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deferred, deterministicIds, testOptions, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -function answer(profile: CompletionProfile, text = "done") { - switch (profile.id) { - case "openai-chat@1": - return { choices: [{ message: { content: text } }] }; - case "openai-responses@1": - return { - status: "completed", - output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text }] }], - }; - case "anthropic-messages@1": - return { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text }] }; - default: - return { - candidates: [{ finishReason: "STOP", content: { role: "model", parts: [{ text }] } }], - }; - } -} - -function options(profile = openaiChat, calls: unknown[] = []): SessionOptions { - return { - configuration: { - agent: "a", - steps: 3, - agents: new Map([ - ["a", { model: "default-model", tools: ["echo"], successors: ["b"] }], - ["b", { model: "b-model", tools: [], successors: [] }], - ]), - policy: { - maxOutputTokens: 16384, - provider: profile.id, - stream: false, - thinking: "off", - thinkingBudgetTokens: null, - }, - }, - bindings: { - id: deterministicIds(), - tools: new Map([ - ["echo", defineTool({ input: z.object({ text: z.string() }), run: ({ text }) => text })], - ]), - providers: new Map( - [openaiChat, openaiResponses, anthropicMessages, googleGenerate].map((p) => [ - p.id, - { - profile: p, - transport: { - baseUrl: "https://example.invalid/v1", - headers: { Authorization: "Bearer SECRET" }, - fetch: (async (_url, init) => { - calls.push(JSON.parse(String(init?.body))); - return Response.json(answer(p)); - }) as typeof fetch, - }, - }, - ]), - ), - }, - persistence: createMemoryPersistence(), - }; -} -for (const profile of [openaiChat, openaiResponses, anthropicMessages, googleGenerate]) { - test(`${profile.id} session restores without effects and forks identical next requests`, async () => { - const calls: unknown[] = []; - const opts = options(profile, calls); - const session = await createSession(opts); - const result = await session.input("hello").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(session.snapshot.durable.records.every((record) => record.version === 2)).toBe(true); - const before = calls.length; - const restored = await restoreSession(opts, session.snapshot.durable.conversation.sessionId); - expect(calls).toHaveLength(before); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); - const branch = await session.fork(); - await branch.input("next").settled; - const branchedRequest = calls.at(-1); - await restored.input("next").settled; - expect(calls.at(-1)).toEqual(branchedRequest); - expect(journalJSONL(session.snapshot.durable)).not.toContain("SECRET"); - expect(journalJSONL(session.snapshot.durable)).not.toContain("example.invalid"); - const compacted = await session.compact([]); - await compacted.input("reset").settled; - expect(JSON.stringify(calls.at(-1))).not.toContain("hello"); - await Promise.all([session.close(), restored.close(), branch.close(), compacted.close()]); - }); -} -test("provider and model patches commit at idle; missing bindings fail admission, restore adopts live defaults", async () => { - const calls: unknown[] = []; - const opts = options(openaiChat, calls); - const session = await createSession(opts); - expect( - (await session.updatePolicy({ provider: anthropicMessages.id, model: "override" })).kind, - ).toBe("accepted"); - await session.input("hello").settled; - expect(calls.at(-1)).toMatchObject({ model: "override", thinking: { type: "disabled" } }); - expect((await session.updatePolicy({ provider: "missing@1" })).kind).toBe("failed"); - const providers = new Map(opts.bindings.providers); - providers.delete(anthropicMessages.id); - const restored = await restoreSession( - { ...opts, bindings: { ...opts.bindings, providers } }, - session.snapshot.durable.conversation.sessionId, - ); - expect(restored.registry).toEqual({ - kind: "pending_adoption", - differences: ["changed policy.provider", "changed policy.model"], - }); - expect(restored.policy).toMatchObject({ provider: openaiChat.id, version: 2 }); - expect(restored.policy?.model).toBeUndefined(); - expect(restored.snapshot.durable.policy).toMatchObject({ - provider: anthropicMessages.id, - model: "override", - }); - await Promise.all([session.close(), restored.close()]); -}); -test("active completion sees frozen selection and a mid-turn policy change selects without blocking it (D6)", async () => { - const opts = options(); - const pending = deferred(); - let body: unknown; - const providers = new Map(opts.bindings.providers); - providers.set(openaiChat.id, { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid", - fetch: (async (_url, init) => { - body = JSON.parse(String(init?.body)); - return pending.promise; - }) as typeof fetch, - }, - }); - const session = await createSession({ ...opts, bindings: { ...opts.bindings, providers } }); - const handle = session.input("hello"); - await until(() => body !== undefined); - expect( - await session.updatePolicy({ provider: anthropicMessages.id, model: "other" }), - ).toMatchObject({ kind: "selected" }); - expect(body).toMatchObject({ model: "default-model" }); - pending.resolve(Response.json(answer(openaiChat))); - await handle.settled; - await session.close(); -}); -test("rejected policy append never starts dependent work or changes durable selection", async () => { - const opts = options(); - const backing = opts.persistence; - let reject = false; - const persistence: SessionPersistence = { - lifetime: backing.lifetime, - putBlob: backing.putBlob.bind(backing), - getBlob: backing.getBlob.bind(backing), - load: backing.load.bind(backing), - getConfig: backing.getConfig.bind(backing), - putConfig: backing.putConfig.bind(backing), - append: (request, signal) => - reject - ? Promise.resolve({ kind: "rejected", message: "offline" }) - : backing.append(request, signal), - }; - const session = await createSession({ ...opts, persistence }); - reject = true; - expect((await session.updatePolicy({ provider: anthropicMessages.id })).kind).toBe("failed"); - expect(session.snapshot.durable.policy?.provider).toBe(openaiChat.id); - await session.close(); -}); -test("provider configuration changes retain format and record the model each step was made with", async () => { - const legacy = testOptions(); - const old = await createSession(legacy); - await old.input("old history").settled; - await old.close(); - const opts = options(); - // Legacy manifest remains exact: provider selection changes policy, not agent configuration. - const restored = await restoreSession( - { - ...opts, - persistence: legacy.persistence, - configuration: { ...opts.configuration, agents: legacy.configuration.agents }, - bindings: { ...opts.bindings, tools: legacy.bindings.tools, id: legacy.bindings.id }, - }, - old.snapshot.durable.conversation.sessionId, - ); - expect((await restored.updatePolicy({ provider: openaiResponses.id, model: "new" })).kind).toBe( - "accepted", - ); - const terminal = await restored.input("new turn").settled; - expect(terminal.kind === "terminal" && terminal.record.outcome.kind).toBe("completed"); - expect(restored.snapshot.durable.records.every((record) => record.version === 2)).toBe(true); - const loaded = await legacy.persistence.load( - restored.snapshot.durable.conversation.sessionId, - new AbortController().signal, - ); - if (loaded.kind !== "loaded") throw new Error("missing journal"); - expect(replay(loaded.batches)).toEqual(restored.snapshot.durable); - const steps = restored.snapshot.durable.records.flatMap((record) => - record.body.kind === "event" && - record.body.event.type === "child" && - record.body.event.event.type === "model_settled" - ? [{ provider: record.body.event.event.provider, model: record.body.event.event.model }] - : [], - ); - expect(steps).toEqual([ - { provider: undefined, model: expect.any(String) }, - { provider: openaiResponses.id, model: "new" }, - ]); - await restored.close(); -}); -test("interrupted provider request recovers without invoking HTTP again", async () => { - const opts = options(); - const pending = deferred(); - let calls = 0; - const providers = new Map(opts.bindings.providers); - providers.set(openaiChat.id, { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => { - calls++; - return pending.promise; - }) as unknown as typeof fetch, - }, - }); - const configured = { ...opts, bindings: { ...opts.bindings, providers } }; - const session = await createSession(configured); - const handle = session.input("hello"); - await until(() => calls === 1); - await session.close(); - await handle.settled; - const restored = await restoreSession( - configured, - session.snapshot.durable.conversation.sessionId, - ); - expect(calls).toBe(1); - expect( - restored.snapshot.durable.records.filter((record) => record.body.kind === "recovery"), - ).toHaveLength(1); - pending.resolve(Response.json(answer(openaiChat))); - await restored.close(); -}); - -function toolResponse(profile: CompletionProfile, name: string, args: unknown) { - switch (profile.id) { - case "openai-chat@1": - return { - choices: [ - { - finish_reason: "tool_calls", - message: { - tool_calls: [ - { id: "c1", type: "function", function: { name, arguments: JSON.stringify(args) } }, - ], - }, - }, - ], - }; - case "openai-responses@1": - return { - status: "completed", - output: [{ type: "function_call", call_id: "c1", name, arguments: JSON.stringify(args) }], - }; - case "anthropic-messages@1": - return { - role: "assistant", - stop_reason: "tool_use", - content: [{ type: "tool_use", id: "c1", name, input: args }], - }; - default: - return { - candidates: [ - { - finishReason: "STOP", - content: { role: "model", parts: [{ functionCall: { id: "c1", name, args } }] }, - }, - ], - }; - } -} -for (const profile of [openaiChat, openaiResponses, anthropicMessages, googleGenerate]) { - test(`${profile.id} leaves tool execution and handoff with the existing turn machine`, async () => { - const opts = options(profile); - const bodies: unknown[] = []; - let attempts = 0; - let toolRuns = 0; - const providers = new Map(opts.bindings.providers); - providers.set(profile.id, { - profile, - transport: { - baseUrl: "https://example.invalid/v1", - fetch: (async (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - const response = - attempts++ === 0 - ? toolResponse(profile, "echo", { text: "result" }) - : attempts === 2 - ? toolResponse(profile, "handoff_to", { agent: "b" }) - : answer(profile); - return Response.json(response); - }) as typeof fetch, - }, - }); - const session = await createSession({ - ...opts, - bindings: { - ...opts.bindings, - providers, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => { - toolRuns++; - return text; - }, - }), - ], - ]), - }, - }); - const terminal = await session.input("use tool then hand off").settled; - expect(terminal.kind === "terminal" && terminal.record.outcome.kind).toBe("completed"); - expect(toolRuns).toBe(1); - expect(attempts).toBe(3); - expect(session.snapshot.durable.conversation.turn).toMatchObject({ - status: "idle", - agent: "b", - }); - expect( - session.snapshot.durable.records.filter((record) => record.body.kind === "tool"), - ).toHaveLength(1); - expect(JSON.stringify(bodies[1])).toContain("result"); - expect(JSON.stringify(bodies[2])).not.toContain("handoff_to"); - await session.close(); - }); -} -test("lost policy acknowledgement reconciles the same v3 selection exactly once", async () => { - const opts = options(); - const backing = opts.persistence; - let lost = false; - const persistence: SessionPersistence = { - lifetime: backing.lifetime, - putBlob: backing.putBlob.bind(backing), - getBlob: backing.getBlob.bind(backing), - load: backing.load.bind(backing), - getConfig: backing.getConfig.bind(backing), - putConfig: backing.putConfig.bind(backing), - async append(request, signal) { - const result = await backing.append(request, signal); - if (!lost && request.records.some((record) => JSON.parse(record).body.kind === "policy")) { - lost = true; - return { kind: "indeterminate", message: "Receipt lost" }; - } - return result; - }, - }; - const session = await createSession({ ...opts, persistence }); - expect((await session.updatePolicy({ provider: openaiResponses.id })).kind).toBe("accepted"); - expect( - session.snapshot.durable.records.filter((record) => record.body.kind === "policy"), - ).toHaveLength(1); - expect(session.snapshot.durable.policy?.provider).toBe(openaiResponses.id); - await session.close(); -}); - -test("provider policy fields cannot be erased with non-JSON undefined patches", async () => { - const session = await createSession(options()); - expect(() => session.updatePolicy({ provider: undefined })).toThrow("undefined"); - expect(session.snapshot.durable.policy?.provider).toBe(openaiChat.id); - await session.close(); -}); diff --git a/packages/core-agent/session/registry-adoption.test.ts b/packages/core-agent/session/registry-adoption.test.ts deleted file mode 100644 index 3c84102d..00000000 --- a/packages/core-agent/session/registry-adoption.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { getLogger } from "@logtape/logtape"; -import { expect, spyOn, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { openaiChat } from "../providers/index.ts"; -import type { SessionPersistence } from "./persistence.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { lostAcknowledgement, scriptedCompletion, testOptions } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -/** One agent on an OpenAI-chat binding whose models map offers only `models`. */ -function providerOptions( - persistence: SessionPersistence, - models: readonly string[], - policy: NonNullable, - bodies: { model: string; messages: unknown }[], -): SessionOptions { - return { - persistence, - configuration: { - agent: "reviewer", - agents: new Map([["reviewer", { model: "sonnet", tools: ["echo"], successors: [] }]]), - steps: 4, - policy, - }, - bindings: { - id: () => crypto.randomUUID(), - tools: new Map([["echo", echo]]), - providers: new Map([ - [ - "anthropic", - { - profile: openaiChat, - models: new Map( - models.map((name) => [name, { wireModel: `wire-${name}`, profile: openaiChat }]), - ), - transport: { - baseUrl: "https://scripted.invalid/v1", - fetch: (async (_url: Parameters[0], init?: RequestInit) => { - bodies.push(JSON.parse(String(init?.body))); - const content = `answer ${bodies.length}`; - return Response.json({ - choices: [{ finish_reason: "stop", message: { content } }], - }); - }) as typeof fetch, - }, - }, - ], - ]), - }, - }; -} - -function observeLogs() { - const records: { event: string; level: string; fields: Record }[] = []; - const logger = getLogger(["labkit", "session"]); - const original = logger.emit.bind(logger); - const spy = spyOn(logger, "emit").mockImplementation((record) => { - records.push({ - event: String(record.rawMessage), - level: record.level, - fields: record.properties, - }); - original(record); - }); - return { records, close: () => spy.mockRestore() }; -} - -/** Records every attempted append ID; `reject` fails matching appends before storage. */ -function recording(port: SessionPersistence, reject?: (appendId: string) => boolean) { - const appends: string[] = []; - const persistence: SessionPersistence = { - lifetime: port.lifetime, - putBlob: port.putBlob.bind(port), - getBlob: port.getBlob.bind(port), - load: port.load.bind(port), - getConfig: port.getConfig.bind(port), - putConfig: port.putConfig.bind(port), - async append(request, signal) { - appends.push(request.appendId); - if (reject?.(request.appendId)) - return { - kind: "rejected", - message: "Disk quota exceeded", - error: { message: "Disk quota exceeded", details: { code: "EDQUOT" } }, - }; - return port.append(request, signal); - }, - }; - return { persistence, appends }; -} - -const echo = defineTool({ input: z.object({ text: z.string() }), run: ({ text }) => text }); - -const widenedEcho = defineTool({ - input: z.object({ text: z.string(), line: z.number().int().optional() }), - run: ({ text }) => text, -}); - -test("widened and added tools are journaled before the first new turn and replay cleanly", async () => { - const port = createMemoryPersistence(); - const original = testOptions({ - persistence: port, - complete: scriptedCompletion([ - { kind: "tools", text: "Echo", calls: [{ id: "c1", name: "echo", args: { text: "x" } }] }, - { kind: "answer", text: "Done" }, - ]), - }); - const session = await createSession(original); - await session.input("First").settled; - await session.close(); - const sessionId = session.snapshot.durable.conversation.sessionId; - const persistedRevision = session.snapshot.durable.revision; - - const capture = observeLogs(); - try { - const requests: { tools?: unknown; messages: unknown }[] = []; - const { persistence, appends } = recording(port); - const live = testOptions({ - persistence, - id: () => crypto.randomUUID(), - tools: new Map([ - ["echo", widenedEcho], - ["extra", echo], - ]), - complete: scriptedCompletion([{ kind: "answer", text: "Again" }], requests), - }); - const restored = await restoreSession(live, sessionId); - const differences = [ - "changed tools.echo.parameters: added properties line", - "added tools.extra", - ]; - expect(restored.registry).toEqual({ kind: "pending_adoption", differences }); - expect(restored.snapshot.durable.revision).toBe(persistedRevision); - expect(appends).toEqual([]); - - const turn = restored.input("Second"); - expect((await turn.accepted).kind).toBe("accepted"); - expect(await turn.settled).toMatchObject({ record: { outcome: { kind: "completed" } } }); - const adoptionId = `configuration/${sessionId}/${persistedRevision}`; - expect(appends[0]).toBe(adoptionId); - const records = restored.snapshot.durable.records.slice(persistedRevision); - expect(records[0]).toMatchObject({ - appendId: adoptionId, - revision: persistedRevision + 1, - body: { kind: "configuration" }, - }); - expect(records[0]!.body).not.toHaveProperty("policy"); - expect(records[0]!.body).not.toHaveProperty("agent"); - expect(records[1]?.body).toMatchObject({ kind: "event", event: { type: "user" } }); - expect(restored.registry).toEqual({ kind: "current" }); - expect(JSON.stringify(requests[0]!.tools)).toContain('"line"'); - - const adopted = capture.records.find((record) => record.event === "session.registry.adopted"); - expect(adopted).toMatchObject({ - level: "info", - fields: { sessionId, appendId: adoptionId, revision: persistedRevision + 1, differences }, - }); - expect( - capture.records.find((record) => record.event === "session.registry.mismatch"), - ).toMatchObject({ - level: "info", - fields: { differences, adoption: "pending", revision: persistedRevision }, - }); - expect( - capture.records.find((record) => record.event === "session.registry.reconciled")?.fields, - ).toMatchObject({ - reconciliation: "tool_registry", - removedTools: [], - addedTools: ["extra"], - changedTools: ["echo"], - }); - expect( - capture.records.find((record) => record.event === "session.restored")?.fields.registry, - ).toBe("pending_adoption"); - expect(capture.records.filter((record) => ["warning", "error"].includes(record.level))).toEqual( - [], - ); - - await restored.close(); - const again = await restoreSession(live, sessionId); - expect(again.registry).toEqual({ kind: "current" }); - expect(again.snapshot.durable).toEqual(restored.snapshot.durable); - expect( - capture.records.filter((record) => record.event === "session.restored").at(-1)?.fields - .registry, - ).toBe("current"); - await again.close(); - } finally { - capture.close(); - } -}); - -test("a removed tool and current agent are reconciled while history is projected verbatim", async () => { - const port = createMemoryPersistence(); - const gone = defineTool({ input: z.object({ path: z.string() }), run: () => "GONE_RESULT" }); - const original = testOptions({ - persistence: port, - agents: new Map([ - ["a", { model: "model-a", systemPrompt: "Agent A", tools: ["echo", "gone"] }], - ["b", { model: "model-b", systemPrompt: "Agent B", tools: ["echo"] }], - ]), - tools: new Map([ - ["echo", echo], - ["gone", gone], - ]), - complete: scriptedCompletion([ - { kind: "tools", text: "Reading", calls: [{ id: "g1", name: "gone", args: { path: "x" } }] }, - { kind: "answer", text: "Read it" }, - ]), - }); - const session = await createSession(original); - await session.input("Read x").settled; - await session.close(); - const sessionId = session.snapshot.durable.conversation.sessionId; - const history = session.snapshot.durable.conversation.log; - const persistedRevision = session.snapshot.durable.revision; - - const capture = observeLogs(); - try { - const requests: { - model: string; - tools?: { function: { name: string } }[]; - messages: unknown; - }[] = []; - const live = testOptions({ - persistence: port, - id: () => crypto.randomUUID(), - agent: "b", - agents: new Map([["b", { model: "model-b", systemPrompt: "Agent B", tools: ["echo"] }]]), - tools: new Map([["echo", echo]]), - complete: scriptedCompletion([{ kind: "answer", text: "Continued" }], requests), - }); - const restored = await restoreSession(live, sessionId); - expect(restored.registry).toEqual({ - kind: "pending_adoption", - differences: ["missing agents.a", "missing tools.gone"], - }); - expect(restored.snapshot.durable.revision).toBe(persistedRevision); - const switched = capture.records.find( - (record) => - record.event === "session.registry.reconciled" && - record.fields.reconciliation === "agent_switched", - ); - expect(switched).toMatchObject({ - level: "warning", - fields: { - sessionId, - previousAgent: "a", - nextAgent: "b", - consequence: "conversation continues with agent b", - }, - }); - - const result = await restored.input("Continue").settled; - expect(result).toMatchObject({ record: { agent: "b", outcome: { kind: "completed" } } }); - const adoption = restored.snapshot.durable.records[persistedRevision]!.body; - expect(adoption).toMatchObject({ - kind: "configuration", - agent: "b", - policy: { version: 1, tools: { b: ["echo"] } }, - }); - expect(restored.registry).toEqual({ kind: "current" }); - - // Stored history is a record of facts: the removed tool's call and result are sent verbatim. - const request = requests[0]!; - expect(request.model).toBe("model-b"); - expect(request.tools?.map((tool) => tool.function.name)).toEqual(["echo"]); - const projected = JSON.stringify(request.messages); - expect(projected).toContain('"name":"gone"'); - expect(projected).toContain('"id":"g1"'); - expect(projected).toContain("GONE_RESULT"); - expect(projected).toContain("Read x"); - expect([...restored.snapshot.durable.conversation.log.slice(0, history.length)]).toEqual([ - ...history, - ]); - - await restored.close(); - const again = await restoreSession(live, sessionId); - expect(again.registry).toEqual({ kind: "current" }); - expect(again.snapshot.durable).toEqual(restored.snapshot.durable); - await again.close(); - } finally { - capture.close(); - } -}); - -test("a rejected adoption append fails the dependent input with its storage cause", async () => { - const port = createMemoryPersistence(); - const session = await createSession(testOptions({ persistence: port })); - await session.close(); - const sessionId = session.snapshot.durable.conversation.sessionId; - const capture = observeLogs(); - try { - let completions = 0; - const { persistence, appends } = recording(port, (appendId) => - appendId.startsWith("configuration/"), - ); - const restored = await restoreSession( - testOptions({ - persistence, - tools: new Map([ - ["echo", defineTool({ input: z.object({ path: z.string() }), run: ({ path }) => path })], - ]), - complete: () => { - completions++; - return { kind: "answer", text: "never" }; - }, - }), - sessionId, - ); - const turn = restored.input("Hello"); - const receipt = await turn.accepted; - const settled = await turn.settled; - expect(receipt).toMatchObject({ - kind: "failed", - error: { - message: "Disk quota exceeded", - classification: "persistence", - operation: { id: `configuration/${sessionId}/1`, kind: "append" }, - details: { originalDetails: { code: "EDQUOT" } }, - }, - }); - expect(settled).toMatchObject({ - kind: "failed", - error: receipt.kind === "failed" ? receipt.error : {}, - }); - expect(appends).toEqual([`configuration/${sessionId}/1`]); - expect(completions).toBe(0); - expect(restored.snapshot.durable.revision).toBe(session.snapshot.durable.revision); - expect(restored.registry.kind).toBe("pending_adoption"); - const failed = capture.records.find( - (record) => record.event === "session.registry.adoption_failed", - ); - expect(failed).toMatchObject({ - level: "error", - fields: { - sessionId, - appendId: `configuration/${sessionId}/1`, - differences: [ - "changed tools.echo.parameters: added properties path; removed properties text; required added path; required removed text", - ], - outcome: "failed", - reason: "Disk quota exceeded", - error: { message: "Disk quota exceeded", operation: { kind: "append" } }, - }, - }); - await restored.close(); - } finally { - capture.close(); - } -}); - -test("an adoption whose acknowledgement is lost reconciles before releasing the turn", async () => { - const port = createMemoryPersistence(); - const session = await createSession(testOptions({ persistence: port })); - await session.close(); - const sessionId = session.snapshot.durable.conversation.sessionId; - const restored = await restoreSession( - testOptions({ - persistence: lostAcknowledgement(port, (records) => - records.some((record) => record.includes('"kind":"configuration"')), - ), - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }).describe("Echo text"), - run: ({ text }) => text, - }), - ], - ]), - }), - sessionId, - ); - expect(restored.registry).toEqual({ - kind: "pending_adoption", - differences: ["changed tools.echo.parameters (other schema keywords)"], - }); - expect(await restored.input("Hello").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - expect( - restored.snapshot.durable.records.filter((record) => record.body.kind === "configuration"), - ).toHaveLength(1); - expect(restored.registry).toEqual({ kind: "current" }); - await restored.close(); -}); - -test("a saved model that is no longer bound replays and the next turn adopts the live model", async () => { - const port = createMemoryPersistence(); - const bodies: { model: string; messages: unknown }[] = []; - const saved = providerOptions( - port, - ["sonnet", "opus"], - { - provider: "anthropic", - model: "sonnet", - maxOutputTokens: 16384, - toolFailure: "return-error-and-continue", - }, - bodies, - ); - const session = await createSession(saved); - await session.input("First question").settled; - await session.close(); - const sessionId = session.snapshot.durable.conversation.sessionId; - const persistedRevision = session.snapshot.durable.revision; - - const capture = observeLogs(); - try { - // The live binding offers only "opus"; the saved records name "sonnet". - const live = providerOptions( - port, - ["opus"], - { provider: "anthropic", model: "opus", maxOutputTokens: 16384 }, - bodies, - ); - const restored = await restoreSession(live, sessionId); - expect(restored.registry).toEqual({ - kind: "pending_adoption", - differences: ["changed policy.model"], - }); - expect(restored.snapshot.durable.revision).toBe(persistedRevision); - expect(restored.snapshot.durable.policy).toMatchObject({ model: "sonnet", version: 0 }); - expect(restored.policy).toMatchObject({ - provider: "anthropic", - model: "opus", - version: 1, - maxOutputTokens: 16384, - toolFailure: "return-error-and-continue", - }); - expect(restored.model).toMatchObject({ model: "opus", wireModel: "wire-opus" }); - const reconciled = capture.records.find( - (record) => - record.event === "session.registry.reconciled" && record.fields.reconciliation === "policy", - ); - expect(reconciled).toMatchObject({ - level: "warning", - fields: { - sessionId, - revision: persistedRevision, - policyVersion: 1, - changedFields: ["model"], - previous: { model: "sonnet" }, - next: { model: "opus" }, - reason: expect.stringContaining("Unknown model sonnet for anthropic"), - consequence: "next turn uses anthropic/opus", - }, - }); - - expect(await restored.input("Second question").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - const adoption = restored.snapshot.durable.records[persistedRevision]!; - expect(String(adoption.appendId)).toBe(`configuration/${sessionId}/${persistedRevision}`); - expect(adoption.body).toMatchObject({ kind: "configuration", policy: restored.policy }); - expect(restored.registry).toEqual({ kind: "current" }); - const request = bodies.at(-1)!; - expect(request.model).toBe("wire-opus"); - const projected = JSON.stringify(request.messages); - expect(projected).toContain("First question"); - expect(projected).toContain("answer 1"); - expect(projected).toContain("Second question"); - expect( - capture.records.find((record) => record.event === "session.registry.adopted")?.fields, - ).toMatchObject({ policyVersion: 1, differences: ["changed policy.model"] }); - - await restored.close(); - const again = await restoreSession(live, sessionId); - expect(again.registry).toEqual({ kind: "current" }); - expect(again.snapshot.durable).toEqual(restored.snapshot.durable); - await again.close(); - expect( - capture.records - .filter((record) => ["warning", "error"].includes(record.level)) - .map((record) => record.event), - ).toEqual(["session.registry.reconciled"]); - } finally { - capture.close(); - } -}); diff --git a/packages/core-agent/session/runtime/bind-options.ts b/packages/core-agent/session/runtime/bind-options.ts deleted file mode 100644 index 9376c18b..00000000 --- a/packages/core-agent/session/runtime/bind-options.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { copyResolvers, initialPolicy as resolveInitialPolicy } from "../../policy/policy.ts"; -import { bindProviders } from "../../providers/transport.ts"; -import type { SessionOptions } from "../session-runtime.ts"; - -/** Validates the bindings of `options` and flattens them with the configuration into one object. */ -export function bindOptions(options: SessionOptions, restoring = false) { - const { configuration, bindings } = options; - const capabilities = { - agents: [...configuration.agents].map( - ([name, agent]) => [name, { ...agent, tools: agent.tools ?? [] }] as const, - ), - }; - if (Boolean(bindings.complete) === Boolean(bindings.providers)) - throw new Error("Bind exactly one completion port or provider registry"); - const providers = bindings.providers ? bindProviders(bindings.providers) : undefined; - const resolvers = copyResolvers({ - ...copyResolvers(bindings.policies), - providerCapabilities: providers?.capabilities, - validateSelection: providers?.validateSelection, - providerStreams: providers?.streams, - permissionRequests: !!bindings.requestPermission, - providerIds: providers ? new Set(providers.ids) : undefined, - }); - const initialPolicy = restoring - ? undefined - : resolveInitialPolicy(capabilities, configuration.steps, configuration.policy, resolvers); - if (!restoring && providers && !initialPolicy?.provider) - throw new Error("Provider-bound sessions require an explicit provider policy"); - const completePort = providers?.complete ?? bindings.complete!; - const normalized = { - ...configuration, - steps: initialPolicy?.steps ?? configuration.steps, - persistence: options.persistence, - sessionId: options.sessionId, - ...bindings, - }; - return { - options: normalized, - resolvers, - initialPolicy, - observe: bindings.observe, - completePort, - describeModel: providers?.describe, - }; -} - -/** Bindings, resolvers and initial policy derived from {@link SessionOptions}. */ -export type BoundOptions = ReturnType; diff --git a/packages/core-agent/session/runtime/configure.ts b/packages/core-agent/session/runtime/configure.ts deleted file mode 100644 index b33260fe..00000000 --- a/packages/core-agent/session/runtime/configure.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { z } from "zod"; - -import { AgentIdSchema, StepsSchema, type AgentId } from "../../agent/types.ts"; -import type { EffectEmitter } from "../../effects/index.ts"; -import type { StreamUpdateSink, ToolUpdateSink } from "../../host/host.ts"; -import { copyRegistries, type CompletionPort, type PermissionPort } from "../../host/ports.ts"; -import { - initialPolicy as resolveInitialPolicy, - type Policy, - type PolicyResolvers, -} from "../../policy/policy.ts"; -import type { SessionPersistence } from "../persistence.ts"; -import type { SessionOptions } from "../session-runtime.ts"; -import { ConfigurationSchema, type Configuration } from "../types.ts"; -import { bindOptions, type BoundOptions } from "./bind-options.ts"; - -/** - * Validated configuration and captured bindings of a session, shared by the session and every - * child session it forks or compacts. - */ -export type ConfiguredSession = Readonly<{ - options: BoundOptions["options"]; - resolvers: PolicyResolvers; - initialPolicy: Policy | undefined; - observe: BoundOptions["observe"]; - completePort: CompletionPort; - describeModel: BoundOptions["describeModel"]; - toolUpdate: ToolUpdateSink | undefined; - streamUpdate: StreamUpdateSink | undefined; - requestPermission: PermissionPort | undefined; - effects: EffectEmitter | undefined; - agentId: AgentId; - steps: z.infer; - agents: ReturnType["agents"]; - tools: ReturnType["tools"]; - configuration: Configuration; - id: () => string; - port: SessionPersistence; - /** The policy a new session would start with under the live bindings. */ - livePolicy: () => Policy; -}>; - -/** Validates `raw` and captures its bindings; `restoring` skips the new-session policy. */ -export function configureSession(raw: SessionOptions, restoring = false): ConfiguredSession { - const { options, resolvers, initialPolicy, observe, completePort, describeModel } = bindOptions( - raw, - restoring, - ); - const toolUpdate = options.toolUpdate; - const streamUpdate = options.streamUpdate; - const requestPermission = options.requestPermission; - const effects = options.effects; - const agentId = AgentIdSchema.parse(options.agent); - const steps = StepsSchema.parse(options.steps); - const { agents, tools } = copyRegistries(options); - if (!agents.has(agentId)) throw new Error(`Unknown agent: ${agentId}`); - const configuration = ConfigurationSchema.parse({ - agents: [...agents], - tools: [...tools].map(([name, tool]) => [name, tool.parameters]), - }); - const id = options.id ?? (() => crypto.randomUUID()); - const port = options.persistence; - if (!port) throw new Error("Session persistence is required"); - return { - options, - resolvers, - initialPolicy, - observe, - completePort, - describeModel, - toolUpdate, - streamUpdate, - requestPermission, - effects, - agentId, - steps, - agents, - tools, - configuration, - id, - port, - livePolicy: () => resolveInitialPolicy(configuration, steps, options.policy, resolvers), - }; -} diff --git a/packages/core-agent/session/runtime/conversation-effects.ts b/packages/core-agent/session/runtime/conversation-effects.ts deleted file mode 100644 index 174bb47f..00000000 --- a/packages/core-agent/session/runtime/conversation-effects.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { ConversationCommand } from "../../agent/agent-conversation.ts"; -import { blobRefs } from "../../agent/content.ts"; -import { projectPolicy } from "../../policy/policy.ts"; -import { - continuationBlobRefs, - copyBranchBlobs, - resolveRequestBlobs, - storeContinuation, -} from "../blobs.ts"; -import { toSeed } from "../session-log.ts"; -import type { Seed } from "../types.ts"; -import type { SessionInstance } from "./instance.ts"; - -/** The conversation command of type `K`. */ -type Effect = Extract; - -async function initializeBranch(ctx: SessionInstance, seed: Seed) { - const controller = new AbortController(); - const owned = { cancel: async () => controller.abort() }; - ctx.storage.add(owned); - try { - await copyBranchBlobs( - ctx.configured.port, - ctx.sessionId, - seed.sessionId, - [ - ...blobRefs([...seed.context, ...seed.log.flatMap((record) => record.messages)]), - ...continuationBlobRefs(seed.continuations ?? []), - ], - controller.signal, - ); - controller.signal.throwIfAborted(); - return await ctx.initializeChild(seed); - } finally { - ctx.storage.delete(owned); - } -} - -function publishBranchReply(ctx: SessionInstance, effect: Effect<"reply">) { - const reply = ctx.branchReplies.get(effect.requestId); - if (reply) - void initializeBranch(ctx, toSeed(ctx.dispatchBoundary, effect.result.state)).then( - (child) => { - ctx.branchReplies.delete(effect.requestId); - if (ctx.actor.snapshot.status === "closed" || ctx.actor.snapshot.status === "failed") { - void child.close(); - reply.reject(new Error("Parent closed before branch publication")); - } else reply.resolve(child); - }, - (error) => { - ctx.branchReplies.delete(effect.requestId); - reply.reject(error); - }, - ); -} - -function dispatchTurn(ctx: SessionInstance, effect: Effect<"turn">) { - const { sessionId } = ctx; - const durable = ctx.actor.snapshot.durable; - const policy = durable.policy; - const prompt = "turn" in effect.command ? ctx.promptInput(effect.command.turn) : undefined; - ctx.host.dispatch(effect, { - prompt, - allowedTools: prompt?.agent.tools, - toolFailure: policy?.toolFailure, - policyVersion: policy?.version, - completionTimeoutMs: policy?.completionTimeoutMs ?? undefined, - toolTimeoutMs: policy?.toolTimeoutMs ?? undefined, - provider: policy, - continuations: durable.continuations, - storeContinuation: (entry, signal) => - storeContinuation(ctx.configured.port, sessionId, entry, signal), - loadBlobs: (request, signal, includeContinuations) => - resolveRequestBlobs(ctx.configured.port, sessionId, request, signal, includeContinuations), - projectPrompt: (value) => - projectPolicy(value, durable.systemInputs, policy, ctx.configured.resolvers), - }); -} - -/** Handler for each command type the conversation machine emits. */ -const conversationEffects: { - [K in ConversationCommand["type"]]: (ctx: SessionInstance, effect: Effect) => void; -} = { reply: publishBranchReply, turn: dispatchTurn }; - -/** Runs one conversation command; a synchronous throw is reported by the caller. */ -export function executeConversationCommand( - ctx: SessionInstance, - effect: Effect, -): void { - conversationEffects[effect.type](ctx, effect); -} diff --git a/packages/core-agent/session/runtime/env-events.ts b/packages/core-agent/session/runtime/env-events.ts deleted file mode 100644 index c934e062..00000000 --- a/packages/core-agent/session/runtime/env-events.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { z } from "zod"; - -import type { SessionRequest } from "../../agent/agent-conversation.ts"; -import { ActorIdSchema, failure, SessionIdSchema } from "../../agent/types.ts"; -import { diagnostic } from "../../logging/index.ts"; -import { EnvEventSchema } from "../events.ts"; -import { AppendIdSchema } from "../persistence.ts"; -import { wireEvent } from "../session-log.ts"; -import type { EnvCommandHandle, SessionRuntime, TerminalResult } from "../session-runtime.ts"; -import { SystemVersionSchema, type SessionInput } from "../types.ts"; -import { selectConfiguration, type SessionInstance } from "./instance.ts"; - -/** A validated public event. */ -type ParsedEvent = z.output; - -/** The validated public event of type `K`. */ -type Event = Extract; - -function branch(ctx: SessionInstance, request: SessionRequest) { - let resolve!: (runtime: SessionRuntime) => void; - let reject!: (error: unknown) => void; - const settled = new Promise((done, failed) => { - resolve = done; - reject = failed; - }); - ctx.branchReplies.set(request.id, { resolve, reject }); - const accepted = ctx.submit({ - kind: "event", - event: wireEvent({ type: "request", request }), - systemVersion: ctx.actor.snapshot.durable.systemVersion, - }); - void accepted.then((receipt) => { - if (receipt.kind !== "accepted") { - ctx.branchReplies.delete(request.id); - reject(new Error(`Branch ${receipt.kind}`)); - } - }); - return { accepted, settled }; -} - -/** - * Journal the live registry ahead of the first new work. That work queues behind this append in - * the session actor, so its commit-time prompt checks run against the registry it actually uses. - */ -export function adopt(ctx: SessionInstance) { - const { sessionId } = ctx; - if (!ctx.adoption.plan || ctx.adoption.submitted) return; - ctx.adoption.submitted = true; - const { body, differences } = ctx.adoption.plan; - const revision = ctx.actor.snapshot.durable.revision; - const appendId = AppendIdSchema.parse(`configuration/${sessionId}/${revision}`); - void ctx.submit(body, undefined, undefined, appendId).then((receipt) => { - if (receipt.kind === "accepted" || receipt.kind === "closed") return; - diagnostic("session", "error", "session.registry.adoption_failed", { - sessionId, - appendId, - revision, - differences, - outcome: receipt.kind, - ...(receipt.kind === "failed" - ? { reason: receipt.message, error: receipt.error } - : { reason: `Registry adoption ${receipt.kind}` }), - consequence: "submissions queued behind the adoption fail with this cause", - }); - }); -} - -function user(ctx: SessionInstance, event: Event<"user">): EnvCommandHandle { - let settle!: (result: TerminalResult) => void; - const settled = new Promise((resolve) => { - settle = resolve; - }); - const accepted = ctx.submit( - { kind: "event", event, systemVersion: ctx.actor.snapshot.durable.systemVersion }, - undefined, - settle, - ); - return { accepted, settled }; -} - -function branchEvent(ctx: SessionInstance, event: Event<"fork" | "compact">): EnvCommandHandle { - const { sessionId } = ctx; - const request = { - id: ActorIdSchema.parse(ctx.configured.id()), - sessionId: SessionIdSchema.parse(ctx.configured.id()), - }; - const handle = branch( - ctx, - event.type === "fork" - ? { ...request, kind: "fork" } - : { ...request, kind: "compact", context: event.context }, - ); - return { - accepted: handle.accepted, - settled: handle.settled.then( - (child) => ({ kind: "branch" as const, session: child }), - (error) => { - const cause = failure(error, { - classification: "execution", - operation: { id: request.id, kind: "branch", sessionId }, - phase: "publication", - }); - return { kind: "failed" as const, message: cause.message, error: cause }; - }, - ), - }; -} - -function close(ctx: SessionInstance): EnvCommandHandle { - const accepted = ctx.send({ type: "close" }).then(() => ({ - kind: "close_acknowledged" as const, - })); - return { - accepted, - settled: accepted.then((receipt) => ({ kind: "acknowledged", receipt })), - }; -} - -function submitAcknowledged( - ctx: SessionInstance, - event: Event<"system" | "abort">, -): EnvCommandHandle { - const input: SessionInput = - event.type === "system" - ? { - kind: "system", - inputs: event.inputs, - version: SystemVersionSchema.parse(ctx.actor.snapshot.durable.systemVersion + 1), - } - : { kind: "event", event, systemVersion: ctx.actor.snapshot.durable.systemVersion }; - const accepted = ctx.submit(input); - return { accepted, settled: accepted.then((receipt) => ({ kind: "acknowledged", receipt })) }; -} - -/** - * A configuration change (D6): selects immediately into the session's configuration store, - * regardless of turn status, and applies it to the journal right away if the conversation is - * idle. Never `busy`. - */ -function policyEvent(ctx: SessionInstance, event: Event<"policy">): EnvCommandHandle { - const accepted = selectConfiguration(ctx, event.patch); - return { accepted, settled: accepted.then((receipt) => ({ kind: "acknowledged", receipt })) }; -} - -/** Handler for each public event type. */ -const envEventHandlers: { - [K in ParsedEvent["type"]]: (ctx: SessionInstance, event: Event) => EnvCommandHandle; -} = { - user, - fork: branchEvent, - compact: branchEvent, - close, - system: submitAcknowledged, - policy: policyEvent, - abort: submitAcknowledged, -}; - -function handleEvent( - ctx: SessionInstance, - event: Event, -): EnvCommandHandle { - return envEventHandlers[event.type](ctx, event); -} - -/** - * Validates and submits a public event. Every event except `abort` and `close` first submits a - * pending registry adoption. - * @throws when `raw` does not match {@link EnvEventSchema}. - */ -export function dispatchEvent(ctx: SessionInstance, raw: unknown): EnvCommandHandle { - const { sessionId } = ctx; - const event = EnvEventSchema.parse(raw); - diagnostic("session", "debug", "event.received", { sessionId, operation: event.type }); - if (event.type !== "close" && event.type !== "abort") adopt(ctx); - return handleEvent(ctx, event); -} diff --git a/packages/core-agent/session/runtime/facade.ts b/packages/core-agent/session/runtime/facade.ts deleted file mode 100644 index 1d950447..00000000 --- a/packages/core-agent/session/runtime/facade.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { parseSessionContext } from "../../agent/prompt.ts"; -import type { EnvEvent } from "../events.ts"; -import { pendingSelection, type CommandReceipt } from "../session-fsm.ts"; -import type { - EnvCommandHandle, - EnvSettlement, - SessionRegistry, - SessionRuntime, - TerminalResult, -} from "../session-runtime.ts"; -import { dispatchEvent } from "./env-events.ts"; -import type { SessionInstance } from "./instance.ts"; - -/** The public runtime of one open session; events go through {@link dispatchEvent}. */ -export function createFacade(ctx: SessionInstance): SessionRuntime { - function dispatch(raw: Extract): { - accepted: Promise; - settled: Promise; - }; - function dispatch(raw: Extract): { - accepted: Promise; - settled: Promise; - }; - function dispatch(raw: unknown): EnvCommandHandle; - function dispatch(raw: unknown): EnvCommandHandle { - return dispatchEvent(ctx, raw); - } - const publishBranch = async (event: EnvEvent): Promise => { - const result = await dispatch(event).settled; - if (result.kind !== "branch") - throw new Error("message" in result ? result.message : "Branch was not published"); - return result.session; - }; - const runtime: SessionRuntime = { - get snapshot() { - return ctx.actor.snapshot; - }, - get lastCompletionUsage() { - return ctx.actor.snapshot.durable.lastCompletionUsage; - }, - get registry(): SessionRegistry { - return ctx.adoption.plan - ? { kind: "pending_adoption", differences: ctx.adoption.plan.differences } - : { kind: "current" }; - }, - get policy() { - return ctx.adoption.plan?.body.policy ?? ctx.actor.snapshot.durable.policy; - }, - get selectedPolicy() { - return pendingSelection(ctx.actor.snapshot) ?? runtime.policy; - }, - get model() { - const { conversation } = ctx.actor.snapshot.durable; - const policy = runtime.policy; - // A pending adoption may switch an unregistered agent; describe what the next turn uses. - const agent = - conversation.turn.status === "idle" - ? (ctx.adoption.plan?.body.agent ?? conversation.turn.agent) - : conversation.turn.turn.agent; - return policy?.provider - ? ctx.configured.describeModel?.( - policy.provider, - policy.model ?? ctx.configured.agents.get(agent)!.model, - ) - : undefined; - }, - dispatch, - fire: (raw) => dispatch(raw).accepted, - input: (input) => - dispatch( - typeof input === "string" ? { type: "user", text: input } : { ...input, type: "user" }, - ), - updateSystem: (inputs) => dispatch({ type: "system", inputs }).accepted, - updatePolicy: (patch) => dispatch({ type: "policy", patch }).accepted, - fork: () => publishBranch({ type: "fork" }), - compact: (context) => { - const validated = parseSessionContext(context); - return publishBranch({ type: "compact", context: validated }); - }, - async close() { - await dispatch({ type: "close" }).accepted; - }, - }; - return runtime; -} diff --git a/packages/core-agent/session/runtime/instance.ts b/packages/core-agent/session/runtime/instance.ts deleted file mode 100644 index 2e6e8d9c..00000000 --- a/packages/core-agent/session/runtime/instance.ts +++ /dev/null @@ -1,340 +0,0 @@ -import type { TurnEvent } from "../../agent/agent-fsm.ts"; -import type { PromptInput } from "../../agent/prompt.ts"; -import type { ActorId, SessionId, TurnData } from "../../agent/types.ts"; -import { Actor } from "../../fsm/fsm.ts"; -import { createHost } from "../../host/host.ts"; -import { diagnostic } from "../../logging/index.ts"; -import { validatePolicy, type Policy, type PolicyPatch } from "../../policy/policy.ts"; -import { AppendIdSchema, type AppendId } from "../persistence.ts"; -import { - decideSession, - type CommandReceipt, - type SessionCommand, - type SessionEvent, - type SessionState, -} from "../session-fsm.ts"; -import { completionUsage, seedConversation, wireEvent, type JournalState } from "../session-log.ts"; -import type { SessionRuntime, TerminalResult } from "../session-runtime.ts"; -import type { Seed, SessionInput } from "../types.ts"; -import type { ConfiguredSession } from "./configure.ts"; -import { adopt } from "./env-events.ts"; -import { createFacade } from "./facade.ts"; -import { registryDifferences, type Adoption } from "./registry-adoption.ts"; -import { executeSessionCommand } from "./session-commands.ts"; - -/** State and callbacks of one open session, passed explicitly to its command and event handlers. */ -export type SessionInstance = { - readonly configured: ConfiguredSession; - readonly sessionId: SessionId; - readonly actor: Actor; - readonly host: ReturnType; - /** Restore-only registry adoption waiting to be journaled, and whether it was submitted. */ - readonly adoption: { plan: Adoption | undefined; submitted: boolean }; - /** Committed state of the latest dispatched submission; branch seeds are cut from it. */ - dispatchBoundary: JournalState; - /** Last logged `status/phase` transition and completion usage operation. */ - readonly observed: { transition: string; usage: string | undefined }; - /** Receipt callbacks by submission ID. */ - readonly receipts: Map void>; - /** Callbacks run once a submission's records commit, by submission ID. */ - readonly afterCommit: Map void>; - /** Settlements of submitted user input, by submission ID, until it is admitted to a turn. */ - readonly admissions: Map void>; - /** Settlements waiting for a turn's terminal record, by turn ID. */ - readonly waiters: Map void)[]>; - /** Settlements of queued user input, by input ID, until it is dequeued. */ - readonly queuedWaiters: Map void>; - /** Fork and compaction requests waiting for their child session, by request ID. */ - readonly branchReplies: Map< - ActorId, - { resolve: (runtime: SessionRuntime) => void; reject: (error: unknown) => void } - >; - /** Storage operations and blob copies that closing the session cancels. */ - readonly storage: Set<{ cancel(): Promise }>; - /** - * Tail of the configuration-store writes in progress, so selections reach the store in the order - * the session actor registered them. - */ - storing: Promise; - readonly send: (event: SessionEvent) => Promise; - readonly submit: ( - input: SessionInput, - committed?: () => void, - settlement?: (result: TerminalResult) => void, - stableAppendId?: AppendId, - ) => Promise; - readonly post: (turnId: ActorId, event: TurnEvent) => void; - readonly promptInput: (turn: TurnData) => PromptInput; - /** Opens a fork or compaction child with the same configured bindings. */ - readonly initializeChild: (seed: Seed) => Promise; -}; - -/** Opens a new session from `seed` and commits its creation record. */ -export async function initializeSession( - configured: ConfiguredSession, - seed: Seed, -): Promise { - const built = openInstance(configured, seedConversation(seed, configured.resolvers)); - const receipt = await built.submit( - { kind: "created", seed }, - undefined, - undefined, - AppendIdSchema.parse(`initialize/${seed.sessionId}`), - ); - if (receipt.kind !== "accepted") { - await built.runtime.close(); - throw new Error(receipt.kind === "failed" ? receipt.message : `Creation ${receipt.kind}`, { - cause: receipt.kind === "failed" ? receipt.error : undefined, - }); - } - return built.runtime; -} - -/** - * Opens one session over the committed state `initial`: its actor, host and runtime facade. - * `restoring` skips the checks that a restore replaces with a registry adoption plan. - */ -export function openInstance( - configured: ConfiguredSession, - initial: JournalState, - restoring = false, -) { - const { resolvers, configuration } = configured; - const sessionId = initial.conversation.sessionId; - // Restored policies are reconciled against live bindings by the adoption plan instead. - if (!restoring && initial.policy) - validatePolicy(initial.policy, initial.configuration, resolvers); - if (!restoring && JSON.stringify(initial.configuration) !== JSON.stringify(configuration)) { - const differences = registryDifferences(initial.configuration, configuration); - throw Object.assign( - new Error( - `Session configuration does not match persisted registry: ${differences.join("; ")}`, - ), - { differences }, - ); - } - const ctx: SessionInstance = { - configured, - sessionId, - adoption: { plan: undefined, submitted: false }, - branchReplies: new Map(), - receipts: new Map(), - afterCommit: new Map(), - admissions: new Map(), - waiters: new Map(), - queuedWaiters: new Map(), - storage: new Set(), - dispatchBoundary: initial, - observed: { transition: "", usage: initial.lastCompletionUsage?.operationId }, - storing: Promise.resolve(), - send: (event) => send(ctx, event), - promptInput: (turn) => promptInput(ctx, turn), - post: (turnId, event) => post(ctx, turnId, event), - submit: (input, committed, settlement, stableAppendId) => - submit(ctx, input, committed, settlement, stableAppendId), - initializeChild: (seed) => initializeSession(configured, seed), - host: createHost( - { - agents: configured.agents, - tools: configured.tools, - sessionId, - requestPermission: configured.requestPermission, - complete: configured.completePort, - storeBlob: (bytes, meta) => - configured.port.putBlob(sessionId, bytes, meta, new AbortController().signal), - effects: configured.effects, - }, - { - turn: (turnId, event) => ctx.post(turnId, event), - toolUpdate: configured.toolUpdate, - streamUpdate: configured.streamUpdate, - tool: (outcome) => { - void ctx.submit({ kind: "tool", ...outcome }, () => ctx.host.releaseTool(outcome)); - }, - }, - ), - actor: new Actor( - { status: "ready", durable: initial, queue: [] }, - (state, event) => decideSession(state, event, resolvers), - (command) => executeSessionCommand(ctx, command), - () => ({ type: "close" }), - ), - }; - const runtime = createFacade(ctx); - /** Restore-only: every registry difference is journaled before the next new work. */ - const pend = (plan: Adoption) => { - ctx.adoption.plan = plan; - }; - /** Restore-only: applies a selection stored before the session last closed. */ - const reselect = (policy: Policy) => { - const id = ctx.configured.id(); - const { promise, resolve } = Promise.withResolvers(); - ctx.receipts.set(id, resolve); - void ctx.send({ type: "reselect", id, policy }); - return promise; - }; - return { runtime, submit: ctx.submit, pend, adopt: () => adopt(ctx), reselect }; -} - -function promptInput(ctx: SessionInstance, turn: TurnData): PromptInput { - const policy = ctx.actor.snapshot.durable.policy; - const resolved = policy?.provider - ? ctx.configured.describeModel?.( - policy.provider, - policy.model ?? ctx.configured.agents.get(turn.agent)!.model, - ) - : undefined; - return { - context: ctx.actor.snapshot.durable.conversation.context, - log: ctx.actor.snapshot.durable.conversation.log, - turn, - ...(resolved - ? { - target: { - provider: resolved.provider, - model: resolved.model, - media: resolved.capabilities.media, - }, - } - : {}), - agent: { - ...ctx.configured.agents.get(turn.agent)!, - tools: - ctx.actor.snapshot.durable.policy?.tools[turn.agent] ?? - ctx.configured.agents.get(turn.agent)!.tools, - }, - }; -} - -function send(ctx: SessionInstance, event: SessionEvent) { - const { sessionId, observed } = ctx; - return ctx.actor.send(event).then((snapshot) => { - const usage = snapshot.durable.lastCompletionUsage; - if (usage && usage.operationId !== observed.usage) { - observed.usage = usage.operationId; - diagnostic("session", "info", "completion.usage.committed", { - sessionId, - turnId: usage.turnId, - childId: usage.operationId, - revision: snapshot.durable.revision, - ...("appendId" in event ? { appendId: event.appendId } : {}), - usage: usage.usage, - message: - "Completion response accounting committed; not a current-context estimate or cumulative cost", - }); - } - const phase = snapshot.durable.conversation.turn.status; - const transition = `${snapshot.status}/${phase}`; - if (transition !== observed.transition) { - diagnostic( - "session", - snapshot.status === "failed" ? "error" : "debug", - "session.transition", - { - sessionId, - turnId: snapshot.durable.conversation.turnId, - previousStatus: observed.transition || undefined, - status: snapshot.status, - phase, - revision: snapshot.durable.revision, - operation: event.type, - queuedSubmissions: snapshot.queue.length, - ...("pending" in snapshot - ? { - appendId: snapshot.pending.request.appendId, - requestId: snapshot.pending.submission.id, - attempts: snapshot.pending.attempts, - reason: - snapshot.status === "reconciling" - ? "Append outcome unknown; verifying committed journal" - : "Waiting for durable append receipt before releasing work", - } - : {}), - ...(snapshot.status === "failed" - ? { reason: snapshot.message, error: snapshot.error } - : {}), - }, - ); - observed.transition = transition; - } - try { - const result = ctx.configured.observe?.(snapshot); - if (result instanceof Promise) void result.catch(() => {}); - } catch { - /* Observation cannot change execution. */ - } - return snapshot; - }); -} - -function post(ctx: SessionInstance, turnId: ActorId, event: TurnEvent) { - const conversation = { type: "child" as const, turnId, event }; - const usage = completionUsage(conversation); - void ctx.submit({ - kind: "event", - event: wireEvent(conversation), - systemVersion: ctx.actor.snapshot.durable.systemVersion, - ...(usage ? { usage } : {}), - }); -} - -/** - * Selects a configuration change (D6). The session actor registers the selection at once, so any - * input sent afterwards waits for it at an idle boundary; it applies `patch` to the configuration - * the next turn runs under, validates it against the live registry, stores it in the persistence - * port's configuration store and applies it at the next boundary between turns. Never `busy`. - */ -export function selectConfiguration( - ctx: SessionInstance, - patch: PolicyPatch, -): Promise { - const id = ctx.configured.id(); - const { promise, resolve } = Promise.withResolvers(); - ctx.receipts.set(id, resolve); - void ctx.send({ type: "select", id, patch }); - return promise.then((receipt) => { - if (receipt.kind !== "failed" || receipt.error.classification !== "admission") return receipt; - const { details } = receipt.error; - // Staging at the boundary names its append; a rejected patch never reached the store. - const staged = - typeof details === "object" && - details !== null && - !Array.isArray(details) && - details.appendId !== undefined; - diagnostic("session", "warning", "configuration.rejected", { - sessionId: ctx.sessionId, - selectionId: id, - turnId: ctx.actor.snapshot.durable.conversation.turnId, - fields: Object.keys(patch), - reason: receipt.message, - error: receipt.error, - consequence: staged - ? "The stored selection no longer validates at the boundary; the configuration in force is unchanged" - : "Nothing stored; the selected configuration is unchanged", - }); - return receipt; - }); -} - -function submit( - ctx: SessionInstance, - input: SessionInput, - committed?: () => void, - settlement?: (result: TerminalResult) => void, - stableAppendId?: AppendId, -): Promise { - const requestId = stableAppendId ?? ctx.configured.id(); - return new Promise((resolve) => { - ctx.receipts.set(requestId, resolve); - if (committed) ctx.afterCommit.set(requestId, committed); - if (settlement) ctx.admissions.set(requestId, settlement); - void ctx.send({ - type: "submit", - submission: { - id: requestId, - appendId: stableAppendId ?? AppendIdSchema.parse(`${ctx.sessionId}/append/${requestId}`), - input, - }, - }); - }); -} diff --git a/packages/core-agent/session/runtime/open.ts b/packages/core-agent/session/runtime/open.ts deleted file mode 100644 index 1c019ae8..00000000 --- a/packages/core-agent/session/runtime/open.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { SessionIdSchema } from "../../agent/types.ts"; -import { freeze } from "../../fsm/fsm.ts"; -import { diagnostic, diagnosticError } from "../../logging/index.ts"; -import { changedPolicyFields } from "../../policy/policy.ts"; -import { AppendIdSchema } from "../persistence.ts"; -import { JournalIntegrityError, replay } from "../session-log.ts"; -import { loadSession } from "../session-operation.ts"; -import type { SessionOptions, SessionRuntime } from "../session-runtime.ts"; -import { SeedSchema } from "../types.ts"; -import { configureSession } from "./configure.ts"; -import { initializeSession, openInstance } from "./instance.ts"; -import { planAdoption, type Adoption } from "./registry-adoption.ts"; - -/** - * Creates a new root session and commits its creation record (append ID - * `initialize/`), which holds the registry, standing instructions and initial policy. - * Resolves once that record is committed. - * @throws (rejects) when the bindings or configuration are invalid: not exactly one of - * `complete`/`providers`, a provider-bound session without `policy.provider`, an unknown `agent`, - * or a policy the bindings reject; or when the creation record does not commit. - */ -export async function createSession(options: SessionOptions): Promise { - const configured = configureSession(options); - const sessionId = SessionIdSchema.parse(configured.options.sessionId ?? configured.id()); - return initializeSession( - configured, - SeedSchema.parse({ - sessionId, - origin: { kind: "root" }, - context: [], - log: [], - agent: configured.agentId, - sequence: 1, - systemInputs: configured.options.systemInputs ?? [], - ...(configured.initialPolicy ? { policy: configured.initialPolicy } : {}), - systemVersion: 0, - configuration: configured.configuration, - }), - ); -} - -/** {@link restoreSession} found no saved journal for this session ID (never saved, or deleted). */ -export class SessionNotFoundError extends Error { - readonly sessionId: string; - - constructor(sessionId: string) { - super("Session not found"); - this.name = "SessionNotFoundError"; - this.sessionId = sessionId; - } -} - -/** - * Reopens a saved session with the live configuration and bindings. Load checks journal integrity - * only; a changed registry, model or policy never prevents reopening. Invokes no completion, tool - * or permission callback. - * - * A turn interrupted by process exit is recovered: a recovery record ends it as failed - * (`interrupted`) without repeating external effects, and accepted queued inputs are cancelled. - * Fork and compaction requests that were waiting for that turn are dropped. Other differences - * from the live bindings are not written yet: {@link SessionRuntime.registry} reports - * `pending_adoption` and {@link SessionRuntime.policy} shows the reconciled policy until the first - * new work commits them in a `configuration` record. - * - * A selection in the persistence port's configuration store that differs from the journaled policy - * was made before the process stopped and never applied. Restore applies it before resolving: - * after recovery, and after the `configuration` record it forces when adoption is pending. A - * stored selection that no longer validates logs `configuration.rejected` and is replaced in the - * store by the policy in force. - * @param rawSessionId ID of the saved session; `options.sessionId` is ignored. - * @throws (rejects) {@link SessionNotFoundError} when no journal is saved under the ID, and - * otherwise when loading fails, the journal fails an integrity - * rule ({@link JournalIntegrityError}), the recovery record does not commit, no live provider - * selection can replace the saved one, or the bindings are invalid. `session.restore_failed` - * logs the stage. - */ -export async function restoreSession( - options: SessionOptions, - rawSessionId: string, -): Promise { - const sessionId = SessionIdSchema.parse(rawSessionId); - const startedAt = performance.now(); - let stage = "configure_bindings"; - try { - const configured = configureSession(options, true); - diagnostic("session", "info", "session.restoring", { sessionId }); - stage = "load_journal"; - const loaded = await loadSession(options.persistence, sessionId); - if (loaded.kind === "not_found") throw new SessionNotFoundError(sessionId); - if (loaded.kind !== "loaded") throw new Error(loaded.message, { cause: loaded.error }); - stage = "replay_journal"; - const journal = replay(loaded.batches); - if (journal.conversation.sessionId !== sessionId || journal.revision !== loaded.revision) { - const last = journal.records.at(-1); - throw new JournalIntegrityError( - journal.conversation.sessionId !== sessionId ? "session_identity" : "revision_sequence", - `The stream of session ${sessionId} at revision ${loaded.revision} holds session ${journal.conversation.sessionId} at revision ${journal.revision}`, - { revision: last?.revision, appendId: last?.appendId, entryId: last?.entryId }, - ); - } - stage = "load_selected_configuration"; - const stored = await configured.port.getConfig(sessionId, new AbortController().signal); - stage = "open_session"; - const built = openInstance( - configured, - freeze({ ...journal, conversation: { ...journal.conversation, pending: [] } }), - true, - ); - if (journal.conversation.turn.status !== "idle" || journal.pendingInputs?.length) { - stage = "recover_interrupted_turn"; - diagnostic("session", "warning", "session.recovering", { - sessionId, - turnId: journal.conversation.turnId, - phase: journal.conversation.turn.status, - revision: journal.revision, - reason: "Interrupted operation; external effects will not be replayed", - }); - const receipt = await built.submit( - { - kind: "recovery", - turnId: journal.conversation.turnId, - reason: "Interrupted session; external effects were not replayed", - }, - undefined, - undefined, - AppendIdSchema.parse(`recovery/${sessionId}/${journal.conversation.turnId}`), - ); - if (receipt.kind !== "accepted") { - await built.runtime.close(); - throw new Error(receipt.kind === "failed" ? receipt.message : `Recovery ${receipt.kind}`, { - cause: receipt.kind === "failed" ? receipt.error : undefined, - }); - } - } - const durable = built.runtime.snapshot.durable; - stage = "plan_registry_adoption"; - let plan: Adoption | undefined; - try { - plan = planAdoption(durable, { - live: configured.configuration, - defaultAgent: configured.agentId, - patchTools: configured.options.policy?.tools, - resolvers: configured.resolvers, - livePolicy: configured.livePolicy, - }); - } catch (error) { - await built.runtime.close(); - throw error; - } - if (plan) { - diagnostic("session", "info", "session.registry.mismatch", { - sessionId, - revision: durable.revision, - differences: plan.differences, - adoption: "pending", - message: - "Persisted registry or provider selection differs from the live bindings; the live one is journaled before the next new work", - }); - for (const reconciliation of plan.reconciliations) - diagnostic("session", reconciliation.level, "session.registry.reconciled", { - sessionId, - revision: durable.revision, - adoption: "pending", - ...reconciliation.fields, - }); - built.pend(plan); - } - if (stored && durable.policy && changedPolicyFields(durable.policy, stored).length) { - stage = "apply_selected_configuration"; - // Applying the selection is new work, so any registry adoption is journaled ahead of it. - built.adopt(); - const receipt = await built.reselect(stored); - if (receipt.kind === "failed" && built.runtime.snapshot.status !== "failed") { - const inForce = built.runtime.policy ?? durable.policy; - diagnostic("session", "warning", "configuration.rejected", { - sessionId, - version: stored.version, - inForceVersion: inForce.version, - reason: receipt.message, - error: receipt.error, - consequence: - "The selection stored before the session closed no longer validates against the live registry; the configuration in force is stored as the selection instead", - }); - await configured.port.putConfig(sessionId, inForce, new AbortController().signal); - } - } - diagnostic("session", "info", "session.restored", { - sessionId, - revision: durable.revision, - durationMs: Math.round(performance.now() - startedAt), - provider: durable.policy?.provider, - model: durable.policy?.model, - registry: built.runtime.registry.kind, - }); - return built.runtime; - } catch (error) { - diagnostic("session", "error", "session.restore_failed", { - sessionId, - stage, - durationMs: Math.round(performance.now() - startedAt), - ...(error instanceof JournalIntegrityError - ? { - rule: error.rule, - revision: error.revision, - appendId: error.appendId, - entryId: error.entryId, - } - : {}), - error: diagnosticError(error), - }); - throw error; - } -} diff --git a/packages/core-agent/session/runtime/registry-adoption.ts b/packages/core-agent/session/runtime/registry-adoption.ts deleted file mode 100644 index f116c9ac..00000000 --- a/packages/core-agent/session/runtime/registry-adoption.ts +++ /dev/null @@ -1,323 +0,0 @@ -import type { z } from "zod"; - -import type { AgentId } from "../../agent/types.ts"; -import { - bindingPolicyFields, - PolicySchema, - resolverPolicyFields, - unresolvedPolicyFields, - validatePolicy, - type Policy, - type PolicyPatch, - type PolicyResolvers, -} from "../../policy/policy.ts"; -import type { JournalState } from "../session-log.ts"; -import type { Configuration, ConfigurationSchema, JournalBody } from "../types.ts"; - -/** - * Name a tool-parameter schema change by its top-level `properties` and `required` delta, without - * copying schema bodies. Other keyword changes are summarized as "other schema keywords". - */ -function schemaDelta( - previous: Readonly>, - current: Readonly>, -) { - const properties = (schema: Readonly>) => - new Map( - schema.properties !== null && typeof schema.properties === "object" - ? Object.entries(schema.properties) - : [], - ); - const required = (schema: Readonly>) => - new Set(Array.isArray(schema.required) ? schema.required.map(String) : []); - const [before, after] = [properties(previous), properties(current)]; - const [wasRequired, isRequired] = [required(previous), required(current)]; - const added = [...after.keys()].filter((name) => !before.has(name)); - const removed = [...before.keys()].filter((name) => !after.has(name)); - const changed = [...after.keys()].filter( - (name) => - before.has(name) && JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name)), - ); - const requiredAdded = [...isRequired].filter((name) => !wasRequired.has(name)); - const requiredRemoved = [...wasRequired].filter((name) => !isRequired.has(name)); - const { properties: _before, required: _wasRequired, ...otherBefore } = previous; - const { properties: _after, required: _isRequired, ...otherAfter } = current; - const parts = [ - ...(added.length ? [`added properties ${added.join(", ")}`] : []), - ...(removed.length ? [`removed properties ${removed.join(", ")}`] : []), - ...(requiredAdded.length ? [`required added ${requiredAdded.join(", ")}`] : []), - ...(requiredRemoved.length ? [`required removed ${requiredRemoved.join(", ")}`] : []), - ...(changed.length - ? [`changed ${changed.length > 1 ? "properties" : "property"} ${changed.join(", ")}`] - : []), - ]; - const other = JSON.stringify(otherBefore) !== JSON.stringify(otherAfter); - if (!parts.length) return other ? " (other schema keywords)" : " (serialization order)"; - return `: ${parts.join("; ")}${other ? "; other schema keywords" : ""}`; -} - -/** Describe binding drift without copying system prompts or JSON-schema bodies into errors. */ -export function registryDifferences( - expected: z.infer, - actual: z.infer, -): string[] { - const differences: string[] = []; - for (const group of ["agents", "tools"] as const) { - const before = new Map(expected[group]); - const after = new Map(actual[group]); - const names = [...new Set([...before.keys(), ...after.keys()])].sort(); - for (const name of names) { - const path = `${group}.${name}`; - if (!after.has(name)) differences.push(`missing ${path}`); - else if (!before.has(name)) differences.push(`added ${path}`); - else if (JSON.stringify(before.get(name)) !== JSON.stringify(after.get(name))) { - if (group === "tools") - differences.push( - `changed ${path}.parameters${schemaDelta( - before.get(name) as Readonly>, - after.get(name) as Readonly>, - )}`, - ); - else { - const previous = before.get(name) as Record; - const current = after.get(name) as Record; - const fields = [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort(); - const changed = fields.filter( - (field) => JSON.stringify(previous[field]) !== JSON.stringify(current[field]), - ); - for (const field of changed) differences.push(`changed ${path}.${field}`); - if (!changed.length) differences.push(`changed ${path} serialization order`); - } - } - } - if ( - JSON.stringify([...before.keys()]) !== JSON.stringify([...after.keys()]) && - names.every((name) => before.has(name) && after.has(name)) - ) - differences.push(`changed ${group} registry order`); - } - return differences; -} - -type Reconciliation = Readonly<{ - level: "info" | "warning"; - fields: Readonly>; -}>; - -/** A pending journaled adoption of the live registry, policy and agent. */ -export type Adoption = Readonly<{ - differences: readonly string[]; - body: Extract; - reconciliations: readonly Reconciliation[]; -}>; - -type AdoptionContext = Readonly<{ - live: Configuration; - defaultAgent: AgentId; - patchTools: PolicyPatch["tools"]; - resolvers: PolicyResolvers; - /** The policy a new session would start with; computed only when a selection is unavailable. */ - livePolicy: () => Policy; -}>; - -/** - * Replace the fewest binding-dependent fields with live defaults so the policy validates against - * today's bindings. The saved provider/model is kept whenever some setting combination allows it. - */ -function reconcileSelection( - policy: Policy, - defaults: Policy, - live: Configuration, - resolvers: PolicyResolvers, -): Policy { - const withDefaults = ( - base: Readonly>, - fields: readonly (keyof Policy)[], - ) => { - const next: Record = { ...base }; - for (const field of fields) - if (defaults[field] === undefined) delete next[field]; - else next[field] = defaults[field]; - return next; - }; - const settings = bindingPolicyFields.filter((field) => field !== "provider" && field !== "model"); - const subsets = Array.from({ length: 2 ** settings.length }, (_, mask) => - settings.filter((_, index) => mask & (1 << index)), - ).sort((a, b) => a.length - b.length); - let first: unknown; - for (const base of [policy, withDefaults(policy, ["provider", "model"])]) - for (const fields of subsets) - try { - return validatePolicy(withDefaults(base, fields), live, resolvers); - } catch (error) { - first ??= error; - } - throw new Error( - `No live provider selection can replace the saved one: ${first instanceof Error ? first.message : String(first)}`, - ); -} - -/** - * Plan the journaled adoption of the live registry and bindings at an idle boundary. Only the - * registry, per-agent tool permissions, resolver IDs the live environment lacks, binding-dependent - * policy fields and an unregistered current agent change; committed history is kept verbatim. - * Returns undefined when nothing differs. - */ -export function planAdoption(state: JournalState, context: AdoptionContext): Adoption | undefined { - const { live, resolvers } = context; - const reconciliations: Reconciliation[] = []; - const persistedTools = new Map(state.configuration.tools); - const liveTools = new Map(live.tools); - const removedTools = [...persistedTools.keys()].filter((name) => !liveTools.has(name)); - const addedTools = [...liveTools.keys()].filter((name) => !persistedTools.has(name)); - const changedTools = [...liveTools].flatMap(([name, parameters]) => - persistedTools.has(name) && - JSON.stringify(persistedTools.get(name)) !== JSON.stringify(parameters) - ? [name] - : [], - ); - if (removedTools.length || addedTools.length || changedTools.length) - reconciliations.push({ - level: "info", - fields: { - reconciliation: "tool_registry", - removedTools, - addedTools, - changedTools, - consequence: "next prompt advertises only live tools; earlier tool calls stay in history", - }, - }); - const c = state.conversation; - const current = c.turn.status === "idle" ? c.turn.agent : c.turn.turn.agent; - const liveAgents = new Map(live.agents); - const agent = liveAgents.has(current) ? undefined : context.defaultAgent; - if (agent) - reconciliations.push({ - level: "warning", - fields: { - reconciliation: "agent_switched", - previousAgent: current, - nextAgent: agent, - consequence: `conversation continues with agent ${agent}`, - }, - }); - const differences = registryDifferences(state.configuration, live); - let policy: Policy | undefined; - if (state.policy) { - // Keep committed permissions for surviving agents; derive new agents as creation does. - const previous = state.policy.tools; - const tools = Object.fromEntries( - live.agents.map(([id, definition]) => [ - id, - (previous[id] ?? context.patchTools?.[id] ?? definition.tools).filter((name) => - definition.tools.some((tool) => tool === name), - ), - ]), - ); - const toolsChanged = - Object.keys(tools).length !== Object.keys(previous).length || - Object.entries(tools).some( - ([id, allowed]) => JSON.stringify(allowed) !== JSON.stringify(previous[id]), - ); - let candidate = toolsChanged ? PolicySchema.parse({ ...state.policy, tools }) : state.policy; - // A saved pack, projection or handoff the live resolvers lack takes the new-session default. - const unresolved = unresolvedPolicyFields(candidate, resolvers); - if (unresolved.length) { - const defaults = context.livePolicy(); - candidate = PolicySchema.parse({ - ...candidate, - ...Object.fromEntries(unresolved.map((field) => [field, defaults[field]])), - }); - } - let unavailable: string | undefined; - try { - validatePolicy(candidate, live, resolvers); - } catch (error) { - unavailable = error instanceof Error ? error.message : String(error); - candidate = reconcileSelection(candidate, context.livePolicy(), live, resolvers); - } - if (candidate !== state.policy) { - policy = validatePolicy({ ...candidate, version: state.policy.version + 1 }, live, resolvers); - for (const id of new Set([...Object.keys(previous), ...liveAgents.keys()])) { - const before = previous[id]; - const after = policy.tools[id]; - if (!after) - reconciliations.push({ - level: "info", - fields: { reconciliation: "policy_agent_removed", agentId: id, removedTools: before }, - }); - else if (!before) - reconciliations.push({ - level: "info", - fields: { reconciliation: "policy_agent_added", agentId: id, allowedTools: after }, - }); - else if (before.some((name) => !after.includes(name))) - reconciliations.push({ - level: "info", - fields: { - reconciliation: "policy_tools_removed", - agentId: id, - removedTools: before.filter((name) => !after.includes(name)), - policyVersion: policy.version, - }, - }); - } - const saved = state.policy; - const next = policy; - const resolved = resolverPolicyFields.filter((field) => saved[field] !== next[field]); - if (resolved.length) { - differences.push(...resolved.map((field) => `changed policy.${field}`)); - const consequences = { - id: `later policy changes start from pack ${next.id}`, - project: `next turn projects history with ${next.project}`, - handoff: `handoffs project with ${next.handoff}`, - }; - reconciliations.push({ - // A new pack ID changes nothing the model sees; a new projection or handoff does. - level: resolved.some((field) => field !== "id") ? "warning" : "info", - fields: { - reconciliation: "policy", - policyVersion: next.version, - changedFields: resolved, - previous: Object.fromEntries(resolved.map((field) => [field, saved[field]])), - next: Object.fromEntries(resolved.map((field) => [field, next[field]])), - reason: `saved policy names resolvers the live environment does not supply: ${resolved - .map((field) => `${field} ${saved[field]}`) - .join(", ")}`, - consequence: resolved.map((field) => consequences[field]).join("; "), - }, - }); - } - const changed = bindingPolicyFields.filter( - (field) => JSON.stringify(saved[field]) !== JSON.stringify(next[field]), - ); - if (changed.length) { - differences.push(...changed.map((field) => `changed policy.${field}`)); - const model = next.model ?? liveAgents.get(agent ?? current)?.model; - reconciliations.push({ - level: "warning", - fields: { - reconciliation: "policy", - policyVersion: next.version, - changedFields: changed, - previous: Object.fromEntries(changed.map((field) => [field, saved[field] ?? null])), - next: Object.fromEntries(changed.map((field) => [field, next[field] ?? null])), - reason: `saved selection is not available in the live bindings: ${unavailable}`, - consequence: `next turn uses ${next.provider ?? "the bound completion port"}/${model}`, - }, - }); - } - } - } - if (!differences.length && !policy && !agent) return undefined; - return { - differences, - reconciliations, - body: { - kind: "configuration", - configuration: live, - ...(policy ? { policy } : {}), - ...(agent ? { agent } : {}), - }, - }; -} diff --git a/packages/core-agent/session/runtime/session-commands.ts b/packages/core-agent/session/runtime/session-commands.ts deleted file mode 100644 index dfa72aaf..00000000 --- a/packages/core-agent/session/runtime/session-commands.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { failure } from "../../agent/types.ts"; -import { diagnostic, diagnosticError } from "../../logging/index.ts"; -import { changedPolicyFields } from "../../policy/policy.ts"; -import type { SessionCommand } from "../session-fsm.ts"; -import { appendOperation, loadOperation } from "../session-operation.ts"; -import type { TerminalResult } from "../session-runtime.ts"; -import { executeConversationCommand } from "./conversation-effects.ts"; -import type { SessionInstance } from "./instance.ts"; - -/** The session actor command of type `K`. */ -type Command = Extract; - -function append(ctx: SessionInstance, command: Command<"append">) { - const actor = appendOperation(ctx.configured.port, command.request); - ctx.storage.add(actor); - void actor.start(); - void actor.result.then((result) => { - ctx.storage.delete(actor); - return ctx.send({ type: "appended", appendId: command.request.appendId, result }); - }); -} - -function load(ctx: SessionInstance, command: Command<"load">) { - const { sessionId } = ctx; - diagnostic("session", "warning", "append.reconciling", { - sessionId, - appendId: command.appendId, - }); - const actor = loadOperation(ctx.configured.port, sessionId); - ctx.storage.add(actor); - void actor.start(); - void actor.result.then((result) => { - ctx.storage.delete(actor); - return ctx.send({ type: "loaded", appendId: command.appendId, result }); - }); -} - -/** Settles waiters and runs the conversation commands of a committed submission, in that order. */ -function releaseCommitted(ctx: SessionInstance, command: Command<"dispatch">) { - const { sessionId } = ctx; - const previousPolicy = ctx.dispatchBoundary.policy; - ctx.dispatchBoundary = command.durable; - const newBodies = command.durable.records - .filter((record) => record.appendId === command.submission.appendId) - .map((record) => record.body); - // An effect record may follow the fact it accounts for (and the terminal record that fact - // produced); it never changes which record ended the append's work. - const terminal = newBodies.findLast((body) => body.kind !== "effect"); - for (const body of newBodies) - if (body.kind === "dequeued") { - const waiting = ctx.queuedWaiters.get(body.inputId); - if (waiting) { - ctx.queuedWaiters.delete(body.inputId); - const turnId = - terminal?.kind === "terminal" ? terminal.turnId : command.durable.conversation.turnId; - ctx.waiters.set(turnId, [...(ctx.waiters.get(turnId) ?? []), waiting]); - } - } - for (const body of newBodies) { - if (body.kind === "policy") { - const toolsChanged = Object.entries(body.policy.tools).some(([agent, tools]) => { - const previous = previousPolicy?.tools[agent] ?? []; - return ( - tools.some((tool) => !previous.includes(tool)) || - previous.some((tool) => !tools.includes(tool)) - ); - }); - const permissionsChanged = previousPolicy?.permissions !== body.policy.permissions; - if (permissionsChanged || toolsChanged) - ctx.host.resetPermissions( - permissionsChanged - ? "Permission mode changed by an applied configuration; remembered tool approvals revoked" - : "Allowed tool scope changed; remembered tool approvals revoked", - { policyVersion: body.policy.version, appendId: command.submission.appendId }, - ); - diagnostic("session", "info", "configuration.applied", { - sessionId, - turnId: command.durable.conversation.turnId, - selectionId: command.submission.id, - appendId: command.submission.appendId, - revision: command.durable.revision, - version: body.policy.version, - previousVersion: previousPolicy?.version, - changedFields: previousPolicy ? changedPolicyFields(previousPolicy, body.policy) : [], - policy: body.policy, - message: "Configuration applied at a boundary between turns; the next turn runs with it", - }); - } - if (body.kind === "configuration" && ctx.adoption.plan) { - diagnostic("session", "info", "session.registry.adopted", { - sessionId, - appendId: command.submission.appendId, - requestId: command.submission.id, - revision: command.durable.revision, - differences: ctx.adoption.plan.differences, - ...(body.agent ? { agentId: body.agent } : {}), - ...(body.policy ? { policyVersion: body.policy.version } : {}), - message: "Live tool/agent registry journaled; later prompts validate against it", - }); - ctx.adoption.plan = undefined; - } - } - const admission = ctx.admissions.get(command.submission.id); - if (admission) { - ctx.admissions.delete(command.submission.id); - const queued = newBodies.find((body) => body.kind === "queued"); - if (queued?.kind === "queued") ctx.queuedWaiters.set(queued.inputId, admission); - else { - const turnId = - terminal?.kind === "terminal" ? terminal.turnId : command.durable.conversation.turnId; - ctx.waiters.set(turnId, [...(ctx.waiters.get(turnId) ?? []), admission]); - } - } - if (command.submission.input.kind === "tool") { - diagnostic("session", "debug", "tool.receipt_committed", { - sessionId, - turnId: command.submission.input.turnId, - batchId: command.submission.input.batchId, - callId: command.submission.input.callId, - appendId: command.submission.appendId, - requestId: command.submission.id, - revision: command.durable.revision, - reason: "Individual tool result is durable; releasing batch gate", - }); - } - // Forward a committed individual result before processing a queued cancellation. - ctx.afterCommit.get(command.submission.id)?.(); - ctx.afterCommit.delete(command.submission.id); - if (terminal?.kind === "terminal") { - // The terminal record now holds only the outcome and agent; the fold's own log entry (which - // this same append just settled) is where the full record — including messages — lives. - const record = command.durable.conversation.log.at(-1)!; - const input = command.submission.input; - const trigger = - input.kind === "event" && input.event.type === "child" ? input.event.event : undefined; - diagnostic("session", record.outcome.kind === "failed" ? "warning" : "info", "turn.settled", { - sessionId, - turnId: terminal.turnId, - operation: "agent_turn", - agentId: record.agent, - message: `Agent turn ${record.outcome.kind}${record.outcome.kind === "failed" ? `: ${record.outcome.error.message}` : ""}`, - ...(trigger - ? { - trigger: trigger.type, - childId: trigger.child.id, - childOperation: trigger.child.kind, - } - : { trigger: input.kind === "event" ? input.event.type : input.kind }), - outcome: record.outcome.kind, - revision: command.durable.revision, - appendId: command.submission.appendId, - requestId: command.submission.id, - stepLimit: command.durable.conversation.allowance, - ...(record.outcome.kind === "failed" - ? { - reason: record.outcome.error.message, - error: diagnosticError(record.outcome.error), - } - : {}), - ...(record.outcome.kind === "exhausted" - ? { reason: "Turn step allowance exhausted; user continuation required" } - : {}), - }); - for (const settle of ctx.waiters.get(terminal.turnId) ?? []) - settle({ kind: "terminal", turnId: terminal.turnId, record }); - ctx.waiters.delete(terminal.turnId); - } - for (const effect of command.commands) { - try { - executeConversationCommand(ctx, effect); - } catch (error) { - diagnostic("session", "error", "dispatch.failed", { - sessionId, - appendId: command.submission.appendId, - operation: effect.type, - error: diagnosticError(error), - ...(effect.type === "turn" - ? { turnId: effect.turnId, childId: effect.command.child.id } - : {}), - }); - if (effect.type === "turn") - ctx.post(effect.turnId, { - type: "failed", - child: effect.command.child, - error: failure(error), - }); - else { - const reply = ctx.branchReplies.get(effect.requestId); - ctx.branchReplies.delete(effect.requestId); - reply?.reject(error); - } - } - } -} - -function reply(ctx: SessionInstance, command: Command<"reply">) { - const { sessionId } = ctx; - diagnostic( - "session", - command.result.kind === "failed" ? "error" : "debug", - "submission.receipt", - { - sessionId, - requestId: command.id, - outcome: command.result.kind, - ...(command.result.kind === "accepted" - ? { - appendId: command.result.receipt.appendId, - revision: command.result.receipt.revision, - } - : {}), - ...(command.result.kind === "failed" - ? { reason: command.result.message, error: command.result.error } - : {}), - }, - ); - ctx.receipts.get(command.id)?.(command.result); - ctx.receipts.delete(command.id); - ctx.afterCommit.delete(command.id); - const settle = ctx.admissions.get(command.id); - if (settle) { - settle({ - kind: "failed", - message: - command.result.kind === "failed" ? command.result.message : `Input ${command.result.kind}`, - error: - command.result.kind === "failed" - ? command.result.error - : failure(`Input ${command.result.kind}`, { - classification: "admission", - operation: { - id: command.id, - kind: "admission", - sessionId, - turnId: ctx.actor.snapshot.durable.conversation.turnId, - }, - phase: "admission", - details: { outcome: command.result.kind }, - }), - }); - ctx.admissions.delete(command.id); - } -} - -function drain(ctx: SessionInstance) { - void ctx.send({ type: "drain" }); -} - -function stop(ctx: SessionInstance) { - const { sessionId } = ctx; - diagnostic("session", "info", "session.stopped", { - sessionId, - status: ctx.actor.snapshot.status, - }); - ctx.host.close(); - for (const actor of ctx.storage) void actor.cancel(); - const result: TerminalResult = - ctx.actor.snapshot.status === "closed" - ? { kind: "closed", message: "Session closed" } - : { - kind: "failed", - message: - ctx.actor.snapshot.status === "failed" - ? ctx.actor.snapshot.error.message - : "Session stopped", - error: - ctx.actor.snapshot.status === "failed" - ? ctx.actor.snapshot.error - : failure("Session stopped", { - classification: "interrupted", - operation: { id: sessionId, kind: "admission", sessionId }, - phase: "stop", - }), - }; - for (const settle of ctx.admissions.values()) settle(result); - ctx.admissions.clear(); - for (const group of ctx.waiters.values()) for (const settle of group) settle(result); - ctx.waiters.clear(); - for (const settle of ctx.queuedWaiters.values()) settle(result); - ctx.queuedWaiters.clear(); - for (const reply of ctx.branchReplies.values()) - reply.reject(result.kind === "failed" ? result.error : new Error(result.message)); - ctx.branchReplies.clear(); -} - -/** Logs a registered selection and, for a caller's selection, writes it to the configuration store. */ -function selected(ctx: SessionInstance, command: Command<"selected">) { - const { sessionId } = ctx; - const state = ctx.actor.snapshot; - const inForce = ctx.adoption.plan?.body.policy ?? state.durable.policy; - // An append in flight may be starting a turn; report the conversation as it will be. - const { turn, turnId } = ("pending" in state ? state.pending.next : state.durable).conversation; - diagnostic("session", "info", "configuration.selected", { - sessionId, - selectionId: command.id, - turnId, - version: command.policy.version, - inForceVersion: inForce?.version, - changedFields: inForce ? changedPolicyFields(inForce, command.policy) : [], - turnStatus: turn.status, - source: command.persist ? "caller" : "restore", - message: - turn.status === "idle" - ? "Configuration selected; applies at this idle boundary once stored" - : "Configuration selected; the running turn keeps its settings and this applies when it ends", - }); - if (!command.persist) return; - const { id, policy } = command; - ctx.storing = ctx.storing.then(async () => { - try { - await ctx.configured.port.putConfig(sessionId, policy, new AbortController().signal); - void ctx.send({ type: "stored", id, policy }); - } catch (error) { - const cause = failure(error, { - classification: "persistence", - operation: { id, kind: "admission", sessionId }, - phase: "select", - }); - diagnostic("session", "error", "configuration.store_failed", { - sessionId, - selectionId: id, - version: policy.version, - reason: cause.message, - error: cause, - consequence: - "Selection not stored or applied; the last stored selection, if any, stays pending", - }); - void ctx.send({ type: "stored", id, policy, error: cause }); - } - }); -} - -/** A selection matched the policy in force; revokes grants if a superseded one changed them. */ -function unchanged(ctx: SessionInstance, command: Command<"unchanged">) { - diagnostic("session", "info", "configuration.unchanged", { - sessionId: ctx.sessionId, - selectionId: command.id, - turnId: ctx.actor.snapshot.durable.conversation.turnId, - version: command.policy.version, - revokesGrants: command.revokesGrants, - message: "Selection matches the configuration in force; nothing recorded", - }); - if (command.revokesGrants) - ctx.host.resetPermissions( - "Permission mode or tool scope was changed and changed back before the configuration applied; remembered tool approvals revoked", - { policyVersion: command.policy.version, selectionId: command.id }, - ); -} - -function revokeGrants(ctx: SessionInstance, command: Command<"revokeGrants">) { - ctx.host.resetPermissions( - "Permission mode or tool scope was changed and changed back before the configuration applied; remembered tool approvals revoked", - { policyVersion: command.policyVersion, selectionId: command.id }, - ); -} - -/** Handler for each command type the session actor emits. */ -const sessionCommandHandlers: { - [K in SessionCommand["type"]]: (ctx: SessionInstance, command: Command) => void; -} = { - append, - load, - dispatch: releaseCommitted, - reply, - drain, - stop, - selected, - unchanged, - revokeGrants, -}; - -/** Runs one session actor command; a synchronous throw makes the actor close the session. */ -export function executeSessionCommand( - ctx: SessionInstance, - command: Command, -): undefined { - sessionCommandHandlers[command.type](ctx, command); - return undefined; -} diff --git a/packages/core-agent/session/session-fixtures.test.ts b/packages/core-agent/session/session-fixtures.test.ts deleted file mode 100644 index 751dc71a..00000000 --- a/packages/core-agent/session/session-fixtures.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { runFixture, runFixtures, scenarios } from "./fixture-runner.ts"; - -type DiagnosticRecord = { level: string } & Record; - -async function diagnostics(directory: string, scenario: string): Promise { - const text = await Bun.file(`${directory}/${scenario}/diagnostics.jsonl`).text(); - return text - .trim() - .split("\n") - .map((line) => JSON.parse(line)); -} - -async function assertDiagnosticArtifacts(directory: string, runId: string) { - const records = await diagnostics(directory, "plain-conversation"); - const aliases = await Bun.file( - `${directory}/plain-conversation/evidence/session-aliases.json`, - ).json(); - const journal = ( - await Bun.file(`${directory}/plain-conversation/evidence/journal-root.jsonl`).text() - ) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const sessionId = Object.keys(aliases)[0]!; - expect(aliases[sessionId]).toContain("root"); - expect( - records.every((record) => record.runId === runId && record.scenario === "plain-conversation"), - ).toBe(true); - const terminal = records.find((record) => record.event === "turn.settled")!; - expect(terminal.sessionId).toBe(sessionId); - expect( - journal.some( - (record) => record.body.kind === "terminal" && record.body.turnId === terminal.turnId, - ), - ).toBe(true); - expect( - records.some((record) => record.level === "debug" && record.event === "append.started"), - ).toBe(true); - expect(records.some((record) => record.event === "host.closed")).toBe(true); - const readable = await Bun.file(`${directory}/plain-conversation/diagnostics.log`).text(); - expect(readable).toContain(sessionId); - expect(readable).toContain("turn.settled"); - expect(readable).toContain(runId); - const rejected = await diagnostics(directory, "rejected-append"); - expect( - rejected.some( - (record) => - record.event === "append.settled" && - record.outcome === "rejected" && - record.reason === "Scripted rejected append", - ), - ).toBe(true); - const failure = await diagnostics(directory, "failure"); - expect( - failure.some((record) => record.event === "child.failed" && record.error !== undefined), - ).toBe(true); - expect( - failure.some((record) => record.event === "turn.settled" && record.outcome === "failed"), - ).toBe(true); -} - -// These aggregate tests run each fixture twice, including durable debug artifacts. -// Individual scenarios below retain the default per-scenario timeout. -test("behavioral evidence and human transcripts are deterministic across repeated runs", async () => { - const first = await runFixtures({ artifactDirectory: ".session-artifacts/test-first" }); - const second = await runFixtures({ artifactDirectory: ".session-artifacts/test-second" }); - expect(first.count).toBe(42); - await assertDiagnosticArtifacts(first.artifactDirectory, first.runId); - expect(second.runId).not.toBe(first.runId); - expect(second.structured).toBe(first.structured); - expect(second.markdown).toBe(first.markdown); -}, 30_000); - -test("policy fixture group remains independently runnable", async () => { - const first = await runFixtures({ version: 2, artifactDirectory: ".session-artifacts/v2-first" }); - const second = await runFixtures({ - version: 2, - artifactDirectory: ".session-artifacts/v2-second", - }); - expect(first.count).toBe(25); - const records = await diagnostics(first.artifactDirectory, "permission-reject-batch"); - expect( - records.some( - (record) => record.event === "permission.decided" && record.decision === "reject_once", - ), - ).toBe(true); - expect(second.structured).toBe(first.structured); - expect(second.markdown).toBe(first.markdown); -}, 30_000); - -// Each example is discoverable and runnable by its own name with bun test -t. -for (const scenario of scenarios) { - test(`session example v${scenario.version}: ${scenario.name} — ${scenario.purpose}`, async () => { - await runFixture(scenario, { - artifactDirectory: `.session-artifacts/examples/v${scenario.version}/${scenario.name}`, - }); - }); -} diff --git a/packages/core-agent/session/session-fsm.test.ts b/packages/core-agent/session/session-fsm.test.ts deleted file mode 100644 index df899767..00000000 --- a/packages/core-agent/session/session-fsm.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { CompletionSchema } from "../agent/types.ts"; -import { PolicySchema } from "../policy/policy.ts"; -import { AppendIdSchema, RevisionSchema } from "./persistence.ts"; -import { decideSession, type SessionEvent, type SessionState } from "./session-fsm.ts"; -import { createSession } from "./session-runtime.ts"; -import { deferred, testOptions, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -async function initial(): Promise { - return (await createSession(testOptions())).snapshot; -} -function submission(state: SessionState, id = "append"): SessionEvent { - return { - type: "submit", - submission: { - id, - appendId: AppendIdSchema.parse(id), - input: { - kind: "event", - event: { type: "user", text: "Go" }, - systemVersion: state.durable.systemVersion, - }, - }, - }; -} -test("stage separates pending state from durable state and emits only append", async () => { - const before = await initial(); - const staged = decideSession(before, submission(before)); - expect(staged.state.status).toBe("committing"); - expect(staged.state.durable).toBe(before.durable); - expect(staged.commands.map((command) => command.type)).toEqual(["append"]); - if (staged.state.status !== "committing") throw new Error("Expected committing"); - expect(staged.state.pending.next.conversation.turn.status).toBe("awaiting_model"); - const stale = decideSession(staged.state, { - type: "appended", - appendId: AppendIdSchema.parse("stale"), - result: { kind: "rejected", message: "no" }, - }); - expect(stale.state).toBe(staged.state); - const result = decideSession(staged.state, { - type: "appended", - appendId: staged.state.pending.request.appendId, - result: { - kind: "committed", - receipt: { - sessionId: before.durable.conversation.sessionId, - appendId: staged.state.pending.request.appendId, - revision: staged.state.pending.next.revision, - }, - }, - }); - expect(result.state.status).toBe("ready"); - expect(result.commands.map((command) => command.type)).toEqual(["dispatch", "reply", "drain"]); -}); -test("rejected writes stop work and settle queued callers", async () => { - const before = await initial(); - const staged = decideSession(before, submission(before)); - const queued = decideSession(staged.state, submission(before, "queued")); - const failed = decideSession(queued.state, { - type: "appended", - appendId: AppendIdSchema.parse("append"), - result: { kind: "rejected", message: "offline" }, - }); - expect(failed.state.status).toBe("failed"); - expect(failed.commands.map((command) => command.type)).toEqual(["stop", "reply", "reply"]); - expect(failed.state.durable).toBe(before.durable); -}); -test("uncertain writes load before retrying; repeated uncertainty fails without dispatch", async () => { - const before = await initial(); - let state = decideSession(before, submission(before)).state; - const lost: SessionEvent = { - type: "appended", - appendId: AppendIdSchema.parse("append"), - result: { kind: "indeterminate", message: "lost" }, - }; - const loaded = { - kind: "loaded" as const, - revision: before.durable.revision, - batches: [ - { - sessionId: before.durable.conversation.sessionId, - expectedRevision: RevisionSchema.parse(0), - appendId: before.durable.records[0]!.appendId, - records: before.durable.records.map((record) => JSON.stringify(record)), - revision: before.durable.revision, - }, - ], - }; - const reconcile = decideSession(state, lost); - expect(reconcile.commands[0]?.type).toBe("load"); - state = reconcile.state; - state = decideSession(state, { - type: "loaded", - appendId: AppendIdSchema.parse("append"), - result: loaded, - }).state; - expect(state.status).toBe("committing"); - state = decideSession(state, lost).state; - state = decideSession(state, { - type: "loaded", - appendId: AppendIdSchema.parse("append"), - result: loaded, - }).state; - expect(state.status).toBe("failed"); -}); -test("abort queued behind a delayed committed input cannot dispatch early", async () => { - const port = createMemoryPersistence(); - const release = deferred(); - let writing = false; - let calls = 0; - const runtime = await createSession( - testOptions({ - persistence: { - ...port, - async append(request, signal) { - const result = await port.append(request, signal); - if (request.expectedRevision === 1) { - writing = true; - await release.promise; - } - return result; - }, - }, - complete: () => { - calls++; - return { kind: "answer", text: "late" }; - }, - }), - ); - const input = runtime.input("Go"); - await until(() => writing); - const abort = runtime.fire({ type: "abort" }); - expect(calls).toBe(0); - expect(runtime.snapshot.durable.conversation.turn.status).toBe("idle"); - release.resolve(); - await abort; - expect(await input.settled).toMatchObject({ - kind: "terminal", - record: { outcome: { kind: "aborted" } }, - }); -}); -test("close settles callers promptly even when storage acknowledgement is delayed", async () => { - const port = createMemoryPersistence(); - const release = deferred(); - let writing = false; - const runtime = await createSession( - testOptions({ - persistence: { - ...port, - async append(request, signal) { - const result = await port.append(request, signal); - if (request.expectedRevision === 1) { - writing = true; - await release.promise; - } - return result; - }, - }, - }), - ); - const input = runtime.input("Go"); - await until(() => writing); - await runtime.close(); - expect((await input.accepted).kind).toBe("closed"); - expect((await input.settled).kind).toBe("closed"); - release.resolve(); - expect( - (await port.load(runtime.snapshot.durable.conversation.sessionId, new AbortController().signal)) - .kind, - ).toBe("loaded"); -}); - -test("duplicate matching receipt cannot redispatch commands after commit", async () => { - const before = await initial(); - const staged = decideSession(before, submission(before)); - if (staged.state.status !== "committing") throw new Error("Expected pending append"); - const event: SessionEvent = { - type: "appended", - appendId: staged.state.pending.request.appendId, - result: { - kind: "committed", - receipt: { - sessionId: before.durable.conversation.sessionId, - appendId: staged.state.pending.request.appendId, - revision: staged.state.pending.next.revision, - }, - }, - }; - const committed = decideSession(staged.state, event); - const repeated = decideSession(committed.state, event); - expect(repeated.state).toBe(committed.state); - expect(repeated.commands).toHaveLength(0); -}); - -test("invalid internal event drained from a queue fails and settles remaining queued callers", async () => { - const before = await initial(); - const staged = decideSession(before, submission(before)); - if (staged.state.status !== "committing") throw new Error("Expected pending append"); - const active = staged.state.pending.next; - if (active.conversation.turn.status !== "awaiting_model") throw new Error("Expected a step"); - const corrupt: SessionEvent = { - type: "submit", - submission: { - id: "bad", - appendId: AppendIdSchema.parse("bad"), - input: { - kind: "event", - systemVersion: active.systemVersion, - event: { - type: "child", - turnId: active.conversation.turnId, - event: { - type: "model_settled", - model: "m", - child: active.conversation.turn.child, - result: { - kind: "succeeded", - value: CompletionSchema.brand<"AdmittedCompletion">().parse({ - kind: "tools", - text: "", - calls: [{ id: "call", name: "undeclared", args: {} }], - }), - }, - }, - }, - }, - }, - }; - const next: SessionEvent = submission(before, "next"); - if (next.type !== "submit") throw new Error("Expected submit"); - const queued = decideSession( - { status: "ready", durable: active, queue: [corrupt.submission, next.submission] }, - { type: "drain" }, - ); - expect(queued.state.status).toBe("failed"); - expect(queued.state.queue).toHaveLength(0); - expect( - queued.commands.filter((command) => command.type === "reply").map((command) => command.id), - ).toEqual(["bad", "next"]); -}); - -test("idle boundary with accepted queued inputs keeps system updates busy", async () => { - const before = await initial(); - const state: SessionState = { - status: "ready", - queue: [], - durable: { - ...before.durable, - pendingInputs: [{ inputId: before.durable.conversation.turnId, text: "accepted" }], - }, - }; - const decision = decideSession(state, { - type: "submit", - submission: { - id: "change", - appendId: AppendIdSchema.parse("change"), - input: { kind: "system", inputs: ["replacement"], version: before.durable.systemVersion }, - }, - }); - expect(decision.state).toBe(state); - expect(decision.commands).toEqual([{ type: "reply", id: "change", result: { kind: "busy" } }]); -}); - -test("a selection is held from the moment it arrives, stored, then applied at the idle boundary before a queued input dequeues", async () => { - const before = await initial(); - const running = decideSession(before, submission(before)); - const selected = decideSession(running.state, { - type: "select", - id: "pick", - patch: { steps: 7 }, - }); - const policy = PolicySchema.parse({ - ...before.durable.policy!, - steps: 7, - version: before.durable.policy!.version + 1, - }); - expect(selected.commands).toEqual([{ type: "selected", id: "pick", policy, persist: true }]); - expect(selected.state.selection).toMatchObject({ id: "pick", stored: false }); - const stored = decideSession(selected.state, { type: "stored", id: "pick", policy }); - expect(stored.commands).toEqual([ - { type: "reply", id: "pick", result: { kind: "selected", id: "pick", policy } }, - ]); - const boundary: SessionState = { - status: "ready", - queue: [], - selection: stored.state.selection, - durable: { - ...before.durable, - pendingInputs: [{ inputId: before.durable.conversation.turnId, text: "queued" }], - }, - }; - const drained = decideSession(boundary, { type: "drain" }); - if (drained.state.status !== "committing") throw new Error("Expected the selection to stage"); - expect(drained.state.selection).toBeUndefined(); - expect(drained.state.pending.submission.input).toEqual({ kind: "policy", policy }); - expect(drained.state.pending.next.pendingInputs).toHaveLength(1); - expect(drained.commands.map((command) => command.type)).toEqual(["append"]); -}); - -test("input sent right after a selection waits at the idle boundary until the selection is stored and applied", async () => { - const before = await initial(); - const selected = decideSession(before, { type: "select", id: "pick", patch: { steps: 7 } }); - const next = submission(before, "next"); - if (next.type !== "submit") throw new Error("Expected submit"); - const waiting = decideSession(selected.state, next); - expect(waiting.state.queue).toEqual([next.submission]); - const held = decideSession(waiting.state, { type: "drain" }); - expect(held).toEqual({ state: waiting.state, commands: [] }); - const policy = PolicySchema.parse({ - ...before.durable.policy!, - steps: 7, - version: before.durable.policy!.version + 1, - }); - const stored = decideSession(held.state, { type: "stored", id: "pick", policy }); - if (stored.state.status !== "committing") throw new Error("Expected the selection to stage"); - expect(stored.state.pending.submission.input).toEqual({ kind: "policy", policy }); - expect(stored.state.queue).toEqual([next.submission]); -}); - -test("a selection matching the configuration in force records nothing", async () => { - const before = await initial(); - const selected = decideSession(before, { type: "select", id: "same", patch: {} }); - const stored = decideSession(selected.state, { - type: "stored", - id: "same", - policy: before.durable.policy!, - }); - expect(stored.state.status).toBe("ready"); - expect(stored.state.selection).toBeUndefined(); - expect(stored.commands).toEqual([ - { type: "reply", id: "same", result: { kind: "ignored" } }, - { type: "unchanged", id: "same", policy: before.durable.policy, revokesGrants: false }, - { type: "drain" }, - ]); -}); - -test("a tool scope changed and changed back during a turn still revokes grants at the boundary", async () => { - const before = await initial(); - const running = decideSession(before, submission(before)); - const off = decideSession(running.state, { - type: "select", - id: "off", - patch: { tools: { a: [] } }, - }); - const back = decideSession(off.state, { - type: "select", - id: "back", - patch: { tools: { a: before.durable.policy!.tools.a! } }, - }); - expect(back.state.selection).toMatchObject({ id: "back", revokesGrants: true }); - const boundary: SessionState = { - status: "ready", - queue: [], - selection: { ...back.state.selection!, stored: true }, - durable: before.durable, - }; - const drained = decideSession(boundary, { type: "drain" }); - expect(drained.commands).toContainEqual({ - type: "unchanged", - id: "back", - policy: before.durable.policy, - revokesGrants: true, - }); -}); - -test("a closed session answers a selection without storing it", async () => { - const before = await initial(); - const closed = decideSession(before, { type: "close" }); - const selected = decideSession(closed.state, { type: "select", id: "late", patch: { steps: 7 } }); - expect(selected.commands).toEqual([{ type: "reply", id: "late", result: { kind: "closed" } }]); -}); diff --git a/packages/core-agent/session/session-fsm.ts b/packages/core-agent/session/session-fsm.ts deleted file mode 100644 index b4d39735..00000000 --- a/packages/core-agent/session/session-fsm.ts +++ /dev/null @@ -1,746 +0,0 @@ -import type { ConversationCommand } from "../agent/agent-conversation.ts"; -import { ActorIdSchema, failure, type Failure } from "../agent/types.ts"; -import type { Decision } from "../fsm/fsm.ts"; -import { - builtinResolvers, - changedPolicyFields, - patchPolicy, - PolicyVersionSchema, - type Policy, - type PolicyPatch, - type PolicyResolvers, -} from "../policy/policy.ts"; -import { AppendIdSchema } from "./persistence.ts"; -import type { AppendId, AppendRequest, AppendResult, LoadResult, Receipt } from "./persistence.ts"; -import { accepts, replay, stage, type JournalState } from "./session-log.ts"; -import { SystemVersionSchema, type SessionInput } from "./types.ts"; - -/** - * How the session answered one submission. - * - * - `accepted`: the staged records committed; `receipt` names the append and the revision it - * reached. The input's conversation commands are dispatched before this reply. - * - `selected`: a configuration selection is stored and pending because a turn is running or - * starting, or a later selection superseded it; `id` correlates it with the - * `configuration.selected`/`configuration.applied` diagnostics. It applies as a "configuration - * applied" record at the next boundary between turns. - * - `ignored`: the input no longer applies (stale or uncorrelated, see `accepts`), or a selection - * matches the configuration in force; nothing was written. - * - `busy`: a `system` change arrived while a turn is running or staged, or while queued inputs - * wait; nothing was written. Configuration selections are never `busy`. - * - `failed`: staging rejected the input (classification `admission`), or the session has failed; - * `error` says which. - * - `closed`: the session closed before the input committed. An append already dispatched may - * still commit. - */ -export type CommandReceipt = - | Readonly<{ kind: "accepted"; receipt: Receipt }> - | Readonly<{ kind: "selected"; id: string; policy: Policy }> - | Readonly<{ kind: "ignored" }> - | Readonly<{ kind: "busy" }> - | Readonly<{ kind: "failed"; message: string; error: Failure }> - | Readonly<{ kind: "closed" }>; - -/** - * One input on its way into the journal. `id` correlates the reply; user input the policy queues - * also takes it as its queued-input ID. `appendId` is the stable ID of the append that carries the - * staged records, reused if that append is retried. - */ -export type Submission = Readonly<{ id: string; appendId: AppendId; input: SessionInput }>; - -/** The one append in flight: its submission, the proposed state, and what to release on commit. */ -type Pending = Readonly<{ - submission: Submission; - /** Proposed state; it becomes `durable` only when the append commits. */ - next: JournalState; - request: AppendRequest; - /** Conversation commands dispatched once the append commits. */ - commands: readonly ConversationCommand[]; - /** Retries of this append after reconciliation found it absent; at most one. */ - attempts: number; - /** Failure that made the last attempt's outcome unknown. */ - uncertainty?: Failure; -}>; - -/** - * A configuration the user selected and not yet applied. `id` correlates the selection's reply, - * its diagnostics and the append that applies it. `stored` turns true once the configuration store - * holds it; the boundary waits for that. `revokesGrants` records that a superseded selection - * changed permissions or tools. `fallback` is the last stored selection this one superseded, kept - * pending again if storing this one fails. - */ -type Selection = Readonly<{ - id: string; - policy: Policy; - stored: boolean; - revokesGrants: boolean; - fallback?: Selection | undefined; -}>; - -type Base = Readonly<{ - durable: JournalState; - queue: readonly Submission[]; - selection?: Selection; -}>; - -/** - * State of the session's journal writer. `durable` is the committed state, as of the last append - * receipt. `queue` holds submissions waiting to be staged, in arrival order; it lives in memory - * only and is not the queued inputs (`JournalState.pendingInputs`). `selection` is the pending - * configuration selection; it is not journaled until it applies. At most one append is in - * flight. - * - * - `ready`: no append in flight. - * - `committing`: `pending` holds a staged append awaiting its storage receipt. - * - `reconciling`: the append's outcome is unknown; the journal is being loaded to find out - * whether it committed. - * - `failed`: storage failed, or an input the session generated itself could not be staged. Every - * later submission fails with the same `error`. - * - `closed`: the session closed; later submissions answer `closed`. - */ -export type SessionState = Base & - ( - | Readonly<{ status: "ready" }> - | Readonly<{ status: "committing" | "reconciling"; pending: Pending }> - | Readonly<{ status: "failed"; message: string; error: Failure }> - | Readonly<{ status: "closed" }> - ); - -/** - * Input to {@link decideSession}. `appended` and `loaded` report the storage operation for - * `appendId`; they are ignored unless that append is in flight. `select` patches the pending - * selection (or the configuration the next boundary applies) and holds it until `stored` reports - * its write to the configuration store. `reselect` holds a selection the store already had when - * the session was restored. `drain` applies a stored selection at an idle boundary, then stages - * the next waiting submission or dequeues the first queued input when no turn is running. - */ -export type SessionEvent = - | { type: "submit"; submission: Submission } - | { type: "select"; id: string; patch: PolicyPatch } - | { type: "reselect"; id: string; policy: Policy } - | { type: "stored"; id: string; policy: Policy; error?: Failure } - | { type: "appended"; appendId: AppendId; result: AppendResult } - | { type: "loaded"; appendId: AppendId; result: LoadResult } - | { type: "drain" } - | { type: "close" }; - -/** - * Effect of a session decision. - * - * - `append`: write the batch to storage. - * - `load`: load the journal to learn whether an append with an unknown outcome committed. - * - `dispatch`: an append committed; release its conversation commands (the turn's next child - * operation, or a branch) against the committed state `durable`. - * - `reply`: answer submission `id`. - * - `drain`: send a `drain` event. - * - `stop`: the session failed or closed; stop owned work and storage operations. - * - `selected`: a selection was registered; when `persist`, write it to the configuration store - * and report the outcome as a `stored` event. - * - `unchanged`: a selection matched the policy in force and was dropped; revoke remembered tool - * approvals when `revokesGrants`. - * - `revokeGrants`: revoke remembered tool approvals before a record that does not itself change - * permissions or tools applies. - */ -export type SessionCommand = - | { type: "append"; request: AppendRequest } - | { type: "load"; appendId: AppendId } - | { - type: "dispatch"; - commands: readonly ConversationCommand[]; - submission: Submission; - durable: JournalState; - } - | { type: "reply"; id: string; result: CommandReceipt } - | { type: "drain" } - | { type: "stop" } - | { type: "selected"; id: string; policy: Policy; persist: boolean } - | { type: "unchanged"; id: string; policy: Policy; revokesGrants: boolean } - | { type: "revokeGrants"; id: string; policyVersion: number }; - -type D = Decision; - -const reply = (id: string, result: CommandReceipt): SessionCommand => ({ - type: "reply", - id, - result, -}); - -function fail( - state: SessionState, - reason: unknown, - phase = "append", - details?: Failure["details"], -): D { - const pending = "pending" in state ? state.pending : undefined; - const original = failure(reason); - const error = failure({ - ...original, - classification: original.classification ?? "persistence", - operation: { - id: pending?.request.appendId ?? state.durable.conversation.sessionId, - kind: phase === "load" ? "load" : "append", - sessionId: state.durable.conversation.sessionId, - turnId: state.durable.conversation.turnId, - ...original.operation, - }, - phase: original.phase ?? phase, - details: { - appendId: pending?.request.appendId ?? null, - expectedRevision: pending?.request.expectedRevision ?? state.durable.revision, - attempts: pending ? pending.attempts + 1 : 0, - precedingFailure: pending?.uncertainty ?? null, - observed: details ?? null, - originalDetails: original.details ?? null, - }, - }); - const message = error.message; - const waiting = [ - ...("pending" in state ? [state.pending.submission.id] : []), - ...state.queue.map((s) => s.id), - ...(state.selection?.stored ? [state.selection.id] : []), - ]; - return { - state: { status: "failed", durable: state.durable, queue: [], message, error }, - commands: [ - { type: "stop" }, - ...waiting.map((id) => reply(id, { kind: "failed", message, error })), - ], - }; -} - -function committed( - state: Extract, - receipt: Receipt, -): D { - const p = state.pending; - if ( - receipt.sessionId !== p.request.sessionId || - receipt.appendId !== p.request.appendId || - receipt.revision !== p.next.revision - ) - return fail(state, "Invalid append receipt", "validate_receipt", { - received: receipt, - expectedRevision: p.next.revision, - }); - return { - state: { - status: "ready", - durable: p.next, - queue: state.queue, - ...(state.selection ? { selection: state.selection } : {}), - }, - commands: [ - { type: "dispatch", commands: p.commands, submission: p.submission, durable: p.next }, - reply(p.submission.id, { kind: "accepted", receipt }), - { type: "drain" }, - ], - }; -} - -/** - * The configuration the next turn runs under when it differs from the committed policy: a pending - * selection, else one being applied by the append in flight; otherwise `undefined`. - */ -export function pendingSelection(state: SessionState): Policy | undefined { - if (state.selection) return state.selection.policy; - return "pending" in state && state.pending.submission.input.kind === "policy" - ? state.pending.submission.input.policy - : undefined; -} - -/** - * Policy and registry the next boundary applies: the committed ones, as changed by the append in - * flight and the submissions waiting behind it (an applied configuration or a registry adoption). - */ -function nextConfiguration(state: SessionState) { - let { policy, configuration } = state.durable; - const inputs = [ - ...("pending" in state ? [state.pending.submission.input] : []), - ...state.queue.map((submission) => submission.input), - ]; - for (const input of inputs) { - if (input.kind === "policy") policy = input.policy; - if (input.kind === "configuration") { - configuration = input.configuration; - policy = input.policy ?? policy; - } - } - return { policy, configuration }; -} - -/** Whether applying `next` over `previous` must revoke remembered tool approvals. */ -function changesGrants(previous: Policy, next: Policy) { - return changedPolicyFields(previous, next).some( - (field) => field === "permissions" || field === "tools", - ); -} - -/** - * Whether `input` must wait for a pending selection to apply first. The session's own boundary - * records (creation, recovery, registry adoption, and the applied configuration itself) do not. - */ -function awaitsSelection(state: SessionState, input: SessionInput) { - return ( - state.selection !== undefined && - state.durable.conversation.turn.status === "idle" && - input.kind !== "created" && - input.kind !== "recovery" && - input.kind !== "configuration" && - input.kind !== "policy" - ); -} - -/** - * A stored selection: applied at once when the session is ready and idle, otherwise answered - * `selected` while a turn runs or starts; an idle append in flight applies it on the next drain. - */ -function settleStored( - state: SessionState, - commands: readonly SessionCommand[], - resolvers: PolicyResolvers, -): D { - if (state.status === "ready") { - const applied = applySelection(state, resolvers); - if (applied) return { ...applied, commands: [...commands, ...applied.commands] }; - } - const { selection } = state; - const running = - state.durable.conversation.turn.status !== "idle" || - ("pending" in state && state.pending.next.conversation.turn.status !== "idle"); - return { - state, - commands: [ - ...commands, - ...(selection && running - ? [reply(selection.id, { kind: "selected", id: selection.id, policy: selection.policy })] - : []), - ], - }; -} - -/** - * At an idle boundary, applies the pending selection: stages a "configuration applied" record - * stamped with the next policy version, or answers `ignored` when it matches the policy in force. - * Holds the boundary (no commands) until the selection is stored, and leaves it pending (returns - * `undefined`) while a turn is running. A selection that no longer validates is dropped with a - * `failed` reply; either way the drain continues. A selection that superseded a permission or tool - * change revokes remembered approvals even when the record itself does not change them. - */ -function applySelection( - state: Extract, - resolvers: PolicyResolvers, -): D | undefined { - const { selection, ...cleared } = state; - if (!selection || state.durable.conversation.turn.status !== "idle") return undefined; - if (!selection.stored) return { state, commands: [] }; - const inForce = state.durable.policy; - if (inForce && !changedPolicyFields(inForce, selection.policy).length) - return { - state: cleared, - commands: [ - reply(selection.id, { kind: "ignored" }), - { - type: "unchanged", - id: selection.id, - policy: inForce, - revokesGrants: selection.revokesGrants, - }, - { type: "drain" }, - ], - }; - const version = PolicyVersionSchema.parse((inForce?.version ?? -1) + 1); - const revoke: SessionCommand[] = - selection.revokesGrants && (!inForce || !changesGrants(inForce, selection.policy)) - ? [{ type: "revokeGrants", id: selection.id, policyVersion: version }] - : []; - const decision = mergeQueue( - decideSession( - { ...cleared, queue: [] }, - { - type: "submit", - submission: { - id: selection.id, - appendId: AppendIdSchema.parse( - `${state.durable.conversation.sessionId}/configuration/${selection.id}`, - ), - input: { kind: "policy", policy: { ...selection.policy, version } }, - }, - }, - resolvers, - ), - state.queue, - ); - return { - ...decision, - commands: [ - ...revoke, - ...decision.commands, - ...(decision.state.status === "ready" ? [{ type: "drain" } as const] : []), - ], - }; -} - -/** - * The session's pure transition function. - * - * A submission that arrives while an append is in flight waits in `queue`. A ready session stages - * it with `stage` under `resolvers` and appends the result. A matching committed receipt makes the - * staged state durable, dispatches its commands and replies `accepted`. An indeterminate append - * moves to `reconciling`: the loaded journal shows whether the append committed with the same - * bytes, and an append absent at the expected revision is retried once. Anything else, and every - * rejected or conflicting append, fails the session and every waiting submission. - * - * A staging failure answers `failed` with classification `admission`. For inputs the session - * generates itself (tool results, child events, dequeues, recovery, registry adoption) it also - * fails the session, so no waiting work is released under the old state. - * - * A configuration selection (`select`) is held in `selection` from the moment it arrives, so later - * input at an idle boundary waits for it; it is never answered `busy`. The `selected` command - * stores it and a `stored` event reports the write. A stored selection is staged as a `policy` - * record whenever the session is ready and the conversation idle, before a queued input dequeues or - * new input is admitted; while a turn runs or starts, the caller is answered `selected`. - */ -export function decideSession( - state: SessionState, - event: SessionEvent, - resolvers: PolicyResolvers = builtinResolvers, -): D { - if (event.type === "close") { - const waiting = [ - ...("pending" in state ? [state.pending.submission.id] : []), - ...state.queue.map((s) => s.id), - ...(state.selection?.stored ? [state.selection.id] : []), - ]; - return { - state: { status: "closed", durable: state.durable, queue: [] }, - commands: [{ type: "stop" }, ...waiting.map((id) => reply(id, { kind: "closed" }))], - }; - } - if (event.type === "submit") { - const s = event.submission; - if (state.status === "closed") return { state, commands: [reply(s.id, { kind: "closed" })] }; - if (state.status === "failed") - return { - state, - commands: [reply(s.id, { kind: "failed", message: state.message, error: state.error })], - }; - // Busy is evaluated at admission, including a staged active turn. Configuration selections are - // never busy (see `select`). - if ( - s.input.kind === "system" && - (Boolean(state.durable.pendingInputs?.length) || - state.durable.conversation.turn.status !== "idle" || - ("pending" in state && state.pending.next.conversation.turn.status !== "idle")) - ) - return { state, commands: [reply(s.id, { kind: "busy" })] }; - if (state.status !== "ready" || state.queue.length || awaitsSelection(state, s.input)) - return { - state: { ...state, queue: [...state.queue, s] }, - commands: state.status === "ready" ? [{ type: "drain" }] : [], - }; - if (!accepts(state.durable, s.input)) - return { state, commands: [reply(s.id, { kind: "ignored" })] }; - try { - // Queued updates use the version at their actual idle boundary. - const input = - s.input.kind === "system" - ? { ...s.input, version: SystemVersionSchema.parse(state.durable.systemVersion + 1) } - : s.input.kind === "event" - ? { ...s.input, systemVersion: state.durable.systemVersion } - : s.input; - const next = stage(state.durable, input, s.appendId, resolvers, ActorIdSchema.parse(s.id)); - const request: AppendRequest = { - sessionId: state.durable.conversation.sessionId, - expectedRevision: state.durable.revision, - appendId: s.appendId, - records: next.records, - }; - return { - state: { - ...state, - status: "committing", - pending: { - submission: s, - next: next.state, - request, - commands: next.commands, - attempts: 0, - }, - }, - commands: [{ type: "append", request }], - }; - } catch (error) { - const cause = failure(error, { - classification: "admission", - operation: { - id: s.id, - kind: "admission", - sessionId: state.durable.conversation.sessionId, - turnId: state.durable.conversation.turnId, - }, - phase: "stage", - details: { inputKind: s.input.kind, appendId: s.appendId }, - }); - const message = cause.message; - // Adoption gates queued work: a rejected adoption must not release that work under the old registry. - if ( - s.input.kind === "tool" || - s.input.kind === "dequeued" || - s.input.kind === "recovery" || - s.input.kind === "configuration" || - (s.input.kind === "event" && s.input.event.type === "child") - ) { - const failed = fail(state, cause); - return { - state: failed.state, - commands: [...failed.commands, reply(s.id, { kind: "failed", message, error: cause })], - }; - } - return { state, commands: [reply(s.id, { kind: "failed", message, error: cause })] }; - } - } - if (event.type === "select" || event.type === "reselect") { - if (state.status === "closed") - return { state, commands: [reply(event.id, { kind: "closed" })] }; - if (state.status === "failed") - return { - state, - commands: [reply(event.id, { kind: "failed", message: state.message, error: state.error })], - }; - const next = nextConfiguration(state); - let policy: Policy; - if (event.type === "reselect") policy = event.policy; - else - try { - const base = state.selection?.policy ?? next.policy; - if (!base) throw new Error("Session has no configuration to select against"); - policy = patchPolicy(base, event.patch, next.configuration, resolvers); - } catch (error) { - const cause = failure(error, { - classification: "admission", - operation: { - id: event.id, - kind: "admission", - sessionId: state.durable.conversation.sessionId, - turnId: state.durable.conversation.turnId, - }, - phase: "stage", - details: { inputKind: "policy" }, - }); - return { - state, - commands: [reply(event.id, { kind: "failed", message: cause.message, error: cause })], - }; - } - const previous = state.selection; - const fallback = previous?.stored ? { ...previous, fallback: undefined } : previous?.fallback; - const selection: Selection = { - id: event.id, - policy, - stored: event.type === "reselect", - // Switching permissions or tools and back before the boundary still revokes grants. - revokesGrants: - previous !== undefined && - (previous.revokesGrants || - (next.policy !== undefined && changesGrants(next.policy, previous.policy))), - fallback, - }; - const commands: SessionCommand[] = [ - ...(previous?.stored - ? [reply(previous.id, { kind: "selected", id: previous.id, policy: previous.policy })] - : []), - { type: "selected", id: selection.id, policy, persist: event.type === "select" }, - ]; - const selected = { ...state, selection }; - return selection.stored - ? settleStored(selected, commands, resolvers) - : { state: selected, commands }; - } - if (event.type === "stored") { - if (state.status === "closed") - return { state, commands: [reply(event.id, { kind: "closed" })] }; - if (state.status === "failed") - return { - state, - commands: [reply(event.id, { kind: "failed", message: state.message, error: state.error })], - }; - const current = state.selection; - if (current?.id !== event.id) { - // A superseded selection: the store held it until the current one is written. - if (event.error) - return { - state, - commands: [ - reply(event.id, { kind: "failed", message: event.error.message, error: event.error }), - ], - }; - return { - state: current - ? { - ...state, - selection: { - ...current, - fallback: { - id: event.id, - policy: event.policy, - stored: true, - revokesGrants: current.revokesGrants, - }, - }, - } - : state, - commands: [reply(event.id, { kind: "selected", id: event.id, policy: event.policy })], - }; - } - if (event.error) { - // The store still holds the last selection written, so that one stays pending. - const commands = [ - reply(event.id, { kind: "failed", message: event.error.message, error: event.error }), - ]; - const reverted = { ...state, selection: current.fallback }; - return current.fallback - ? settleStored(reverted, commands, resolvers) - : { state: reverted, commands: [...commands, { type: "drain" }] }; - } - return settleStored({ ...state, selection: { ...current, stored: true } }, [], resolvers); - } - if (event.type === "drain") { - if (state.status !== "ready") return { state, commands: [] }; - const applied = applySelection(state, resolvers); - if (applied) return applied; - const queuedInput = state.durable.pendingInputs?.[0]; - if (queuedInput && state.durable.conversation.turn.status === "idle") { - return mergeQueue( - decideSession( - { ...state, queue: [] }, - { - type: "submit", - submission: { - id: `dequeue/${queuedInput.inputId}`, - appendId: AppendIdSchema.parse( - `${state.durable.conversation.sessionId}/dequeue/${queuedInput.inputId}`, - ), - input: { - kind: "dequeued", - inputId: queuedInput.inputId, - policyVersion: state.durable.policy!.version, - }, - }, - }, - resolvers, - ), - state.queue, - ); - } - if (!state.queue.length) return { state, commands: [] }; - const [submission, ...queue] = state.queue; - const decision = mergeQueue( - decideSession( - { ...state, queue: [] }, - { type: "submit", submission: submission! }, - resolvers, - ), - queue, - ); - return { - state: decision.state, - commands: - decision.state.status === "ready" - ? [...decision.commands, { type: "drain" }] - : decision.commands, - }; - } - if ( - (state.status !== "committing" && state.status !== "reconciling") || - event.appendId !== state.pending.request.appendId - ) - return { state, commands: [] }; - if (event.type === "appended" && state.status === "committing") { - if (event.result.kind === "committed") return committed(state, event.result.receipt); - if (event.result.kind === "indeterminate") - return { - state: { - ...state, - status: "reconciling", - pending: { - ...state.pending, - uncertainty: - event.result.error ?? - failure(event.result.message, { - classification: "persistence", - operation: { - id: event.appendId, - kind: "append", - sessionId: state.durable.conversation.sessionId, - }, - phase: "append", - }), - }, - }, - commands: [{ type: "load", appendId: event.appendId }], - }; - return fail( - state, - event.result.kind === "conflict" - ? "Persistence revision conflict" - : (event.result.error ?? event.result.message), - "append", - event.result.kind === "conflict" - ? { outcome: "conflict", actualRevision: event.result.revision } - : { outcome: "rejected" }, - ); - } - if (event.type === "loaded" && state.status === "reconciling") { - const loaded = event.result; - if (loaded.kind === "failed") return fail(state, loaded.error ?? loaded.message, "load"); - const p = state.pending; - if (loaded.kind === "loaded") { - try { - if (replay(loaded.batches).revision !== loaded.revision) - throw new Error("Load revision mismatch"); - } catch (error) { - return fail(state, error, "reconcile"); - } - const found = loaded.batches.find((batch) => batch.appendId === p.request.appendId); - if (found) { - if ( - found.expectedRevision !== p.request.expectedRevision || - JSON.stringify(found.records) !== JSON.stringify(p.request.records) || - loaded.revision !== found.revision - ) - return fail(state, "Reconciliation content or writer conflict", "reconcile", { - actualRevision: loaded.revision, - }); - return committed(state, { - sessionId: found.sessionId, - appendId: found.appendId, - revision: found.revision, - }); - } - } - if ( - (loaded.kind === "not_found" ? 0 : loaded.revision) !== p.request.expectedRevision || - p.attempts >= 1 - ) - return fail(state, "Unable to reconcile append", "reconcile"); - return { - state: { ...state, status: "committing", pending: { ...p, attempts: p.attempts + 1 } }, - commands: [{ type: "append", request: p.request }], - }; - } - return { state, commands: [] }; -} - -function mergeQueue(decision: D, queue: readonly Submission[]): D { - if (decision.state.status === "failed" || decision.state.status === "closed") { - const result: CommandReceipt = - decision.state.status === "closed" - ? { kind: "closed" } - : { kind: "failed", message: decision.state.message, error: decision.state.error }; - return { - state: decision.state, - commands: [...decision.commands, ...queue.map((submission) => reply(submission.id, result))], - }; - } - return { ...decision, state: { ...decision.state, queue: [...decision.state.queue, ...queue] } }; -} diff --git a/packages/core-agent/session/session-log.test.ts b/packages/core-agent/session/session-log.test.ts deleted file mode 100644 index 68a2ad95..00000000 --- a/packages/core-agent/session/session-log.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { decodeRecord, encodeRecord, replay } from "./session-log.ts"; -import { createSession } from "./session-runtime.ts"; -import { scriptedCompletion, testOptions } from "./test-support.ts"; - -async function fixture() { - const options = testOptions({ - complete: scriptedCompletion([ - { kind: "tools", text: "work", calls: [{ id: "c", name: "echo", args: { text: "result" } }] }, - { kind: "answer", text: "done" }, - ]), - }); - const session = await createSession(options); - await session.input("Go").settled; - const loaded = await options.persistence.load( - session.snapshot.durable.conversation.sessionId, - new AbortController().signal, - ); - if (loaded.kind !== "loaded") throw new Error("Expected stream"); - return { session, batches: loaded.batches }; -} - -test("codec round trips frozen values and replay reproduces durable state", async () => { - const { session, batches } = await fixture(); - expect(replay(batches)).toEqual(session.snapshot.durable); - const record = decodeRecord(batches[0]!.records[0]!); - expect(Object.isFrozen(record.body)).toBe(true); - expect(encodeRecord(record)).toBe(batches[0]!.records[0]!); -}); diff --git a/packages/core-agent/session/session-log.ts b/packages/core-agent/session/session-log.ts deleted file mode 100644 index d0fbd7aa..00000000 --- a/packages/core-agent/session/session-log.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { TurnRecord } from "../agent/types.ts"; -import type { Continuation } from "../providers/types.ts"; -import type { JournalState } from "./journal/state.ts"; -import { SeedSchema, type Seed } from "./types.ts"; - -/** - * The seed that starts a fork or compaction child session (not a child operation) from - * `conversation`. It carries `state`'s registry, policy and standing instructions. Continuations - * are kept only for a fork, and only those whose assistant message is in the context or log. - * - * @param conversation Conversation to seed from, defaulting to `state.conversation`. A branch - * passes its branched snapshot, which already holds the child's session ID and origin. - * @throws Error when `conversation` has a running turn. - */ -export function toSeed(state: JournalState, conversation = state.conversation): Seed { - if (conversation.turn.status !== "idle") - throw new Error("Initialization requires an idle boundary"); - return SeedSchema.parse({ - sessionId: conversation.sessionId, - origin: conversation.origin, - context: conversation.context, - log: conversation.log, - sequence: conversation.sequence, - agent: conversation.turn.agent, - systemInputs: state.systemInputs, - systemVersion: state.systemVersion, - configuration: state.configuration, - policy: state.policy, - ...(state.continuations?.length && conversation.origin.kind !== "compaction" - ? { - continuations: state.continuations.filter((entry: Continuation) => - [ - ...conversation.context, - ...conversation.log.flatMap((record: TurnRecord) => record.messages), - ].some( - (message) => - message.role === "assistant" && - message.owner?.turnId === entry.owner.turnId && - message.owner.generation === entry.owner.generation, - ), - ), - } - : {}), - }); -} - -// Types -export type { ToolEntry, LastCompletionUsage, JournalState } from "./journal/state.ts"; -export type { Fold } from "./journal/state.ts"; -export type { JournalIntegrityRule, JournalLocation } from "./journal/replay.ts"; - -// Fold and Reducer -export { load } from "./journal/state.ts"; - -// Public helpers -export { accepts, partialResults } from "./journal/shared.ts"; - -// Encoding/Decoding -export { wireEvent, completionUsage, encodeRecord, decodeRecord } from "./journal/codec.ts"; - -// Seeding -export { seedConversation, foldSeed } from "./journal/seed.ts"; - -// Staging and replay -export { stage, stageCreation } from "./journal/stage.ts"; -export { replay, JournalIntegrityError } from "./journal/replay.ts"; - -// Reducer -export { reduce } from "./journal/reduce.ts"; - -// Domain event -export { domainEvent } from "./journal/domain-event.ts"; - -// Rendering -export { journalJSONL, journalMarkdown } from "./journal/render.ts"; diff --git a/packages/core-agent/session/session-operation.test.ts b/packages/core-agent/session/session-operation.test.ts deleted file mode 100644 index 9b65a21f..00000000 --- a/packages/core-agent/session/session-operation.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { SessionIdSchema } from "../agent/types.ts"; -import { AppendIdSchema, INITIAL_REVISION } from "./persistence.ts"; -import { appendOperation, loadOperation } from "./session-operation.ts"; -import { deferred, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -const sessionId = SessionIdSchema.parse("00000000-0000-4000-8000-000000000001"); -const request = { - sessionId, - expectedRevision: INITIAL_REVISION, - appendId: AppendIdSchema.parse("a"), - records: ["one"], -}; -test("append cancellation retains the actual committed outcome after a delayed receipt", async () => { - const port = createMemoryPersistence(); - const release = deferred(); - let invoked = false; - const actor = appendOperation( - { - ...port, - async append(request, signal) { - const result = await port.append(request, signal); - invoked = true; - await release.promise; - return result; - }, - }, - request, - ); - await actor.start(); - await until(() => invoked); - await actor.cancel(); - expect(actor.snapshot.status).toBe("running"); - release.resolve(); - expect((await actor.result).kind).toBe("committed"); - expect(actor.snapshot.ref.kind).toBe("append"); -}); -test("thrown append errors are indeterminate; load failures remain typed", async () => { - const port = createMemoryPersistence(); - const actor = appendOperation( - { - ...port, - async append() { - throw new Error("Lost connection"); - }, - }, - request, - ); - await actor.start(); - expect(await actor.result).toMatchObject({ - kind: "indeterminate", - message: "Lost connection", - error: { operation: { kind: "append", id: "a" }, cause: { message: "Lost connection" } }, - }); - const load = loadOperation( - { - ...port, - async load() { - throw new Error("offline"); - }, - }, - sessionId, - ); - await load.start(); - expect(await load.result).toMatchObject({ - kind: "failed", - message: "offline", - error: { operation: { kind: "load" }, cause: { message: "offline" } }, - }); -}); -test("cancel before start is known uncommitted only when the adapter certifies it", async () => { - const actor = appendOperation(createMemoryPersistence(), request); - await actor.cancel(); - await actor.start(); - expect((await actor.result).kind).toBe("rejected"); -}); diff --git a/packages/core-agent/session/session-operation.ts b/packages/core-agent/session/session-operation.ts deleted file mode 100644 index 536242c3..00000000 --- a/packages/core-agent/session/session-operation.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { failure, type SessionId } from "../agent/types.ts"; -import { Actor, type Decision } from "../fsm/fsm.ts"; -import { diagnostic, diagnosticError } from "../logging/index.ts"; -import { - AppendResultSchema, - LoadResultSchema, - type AppendRequest, - type AppendResult, - type LoadResult, - type SessionPersistence, -} from "./persistence.ts"; - -/** Identity of one storage operation: an append, by append ID, or a load, by session ID. */ -export type StorageRef = Readonly<{ kind: "load" | "append"; id: string }>; - -/** - * State of one storage operation. `ready`: created, not started. `running`: the port call is in - * progress. `settled`: `result` is final. `cancellationRequested` means the port's AbortSignal was - * aborted; it does not certify that nothing was written. - */ -export type StorageState = - | Readonly<{ status: "ready"; ref: StorageRef; cancellationRequested: boolean }> - | Readonly<{ status: "running"; ref: StorageRef; cancellationRequested: boolean }> - | Readonly<{ status: "settled"; ref: StorageRef; result: T }>; -/** Based on agent/operation-actor.ts. Cancellation signals I/O but never certifies rollback. */ -function operation( - ref: StorageRef, - run: (signal: AbortSignal) => Promise, - failed: (error: unknown) => T, - sessionId: SessionId, -) { - type Event = { type: "start" } | { type: "cancel" } | { type: "settled"; result: T }; - type Command = { type: "run" } | { type: "cancel" } | { type: "notify"; result: T }; - const controller = new AbortController(); - let resolve!: (result: T) => void; - const result = new Promise((done) => { - resolve = done; - }); - const decide = (state: StorageState, event: Event): Decision, Command> => { - if (state.status === "settled") return { state, commands: [] }; - if (event.type === "cancel") - return { state: { ...state, cancellationRequested: true }, commands: [{ type: "cancel" }] }; - if (event.type === "start" && state.status === "ready") - return { - state: { status: "running", ref, cancellationRequested: state.cancellationRequested }, - commands: [{ type: "run" }], - }; - if (event.type === "settled") - return { - state: { status: "settled", ref, result: event.result }, - commands: [{ type: "notify", result: event.result }], - }; - return { state, commands: [] }; - }; - const actor = new Actor, Event, Command>( - { status: "ready", ref, cancellationRequested: false }, - decide, - (command) => { - if (command.type === "cancel") { - diagnostic("persistence", "debug", "storage.cancellation_requested", { - sessionId, - operation: ref.kind, - appendId: ref.kind === "append" ? ref.id : undefined, - }); - controller.abort(); - } - if (command.type === "notify") resolve(command.result); - if (command.type === "run") - void Promise.resolve() - .then(() => run(controller.signal)) - .catch(failed) - .then((result) => actor.send({ type: "settled", result })); - return undefined; - }, - (_, error) => ({ type: "settled", result: failed(error) }), - ); - return { - get snapshot() { - return actor.snapshot; - }, - result, - start: () => actor.send({ type: "start" }), - cancel: () => actor.send({ type: "cancel" }), - }; -} - -/** - * One `port.append` call as a storage operation. The call runs once `start()` is called; `result` - * resolves with its outcome and never rejects. A port that throws or answers malformed yields - * `indeterminate`, because the batch may have committed. `rejected` and `indeterminate` results - * carry a structured `persistence` failure. - * - * `cancel()` aborts the port's signal, before or during the call; only the adapter's result says - * whether anything was written. Neither method has an effect once the operation has settled. - */ -export function appendOperation(port: SessionPersistence, request: AppendRequest) { - const startedAt = performance.now(); - return operation( - { kind: "append", id: request.appendId }, - async (signal) => { - diagnostic("persistence", "debug", "append.started", { - sessionId: request.sessionId, - appendId: request.appendId, - expectedRevision: request.expectedRevision, - count: request.records.length, - }); - const raw = AppendResultSchema.parse(await port.append(request, signal)); - const result = - raw.kind === "rejected" || raw.kind === "indeterminate" - ? { - ...raw, - error: failure(raw.error ?? raw.message, { - classification: "persistence", - operation: { id: request.appendId, kind: "append", sessionId: request.sessionId }, - phase: "append", - details: { outcome: raw.kind, expectedRevision: request.expectedRevision }, - }), - } - : raw; - diagnostic( - "persistence", - result.kind === "committed" ? "debug" : "warning", - "append.settled", - { - sessionId: request.sessionId, - appendId: request.appendId, - outcome: result.kind, - revision: - result.kind === "committed" - ? result.receipt.revision - : result.kind === "conflict" - ? result.revision - : undefined, - expectedRevision: request.expectedRevision, - durationMs: Math.round(performance.now() - startedAt), - ...("message" in result ? { reason: result.message, error: result.error } : {}), - }, - ); - return result; - }, - (error) => { - diagnostic("persistence", "warning", "append.indeterminate", { - sessionId: request.sessionId, - appendId: request.appendId, - expectedRevision: request.expectedRevision, - durationMs: Math.round(performance.now() - startedAt), - error: diagnosticError(error), - reason: "Append threw; commit outcome unknown until reconciliation", - }); - const cause = failure(error, { - classification: "persistence", - operation: { id: request.appendId, kind: "append", sessionId: request.sessionId }, - phase: "append", - details: { outcome: "indeterminate", expectedRevision: request.expectedRevision }, - }); - return { kind: "indeterminate", message: cause.message, error: cause }; - }, - request.sessionId, - ); -} - -/** - * One `port.load` call as a storage operation. The call runs once `start()` is called; `result` - * resolves with the committed batches, `not_found` or `failed`, and never rejects. A port that - * throws or answers malformed yields `failed`. Only the reply's shape is checked; journal integrity - * is checked by `replay`. - */ -export function loadOperation(port: SessionPersistence, sessionId: SessionId) { - const startedAt = performance.now(); - return operation( - { kind: "load", id: sessionId }, - async (signal) => { - diagnostic("persistence", "debug", "load.started", { sessionId }); - const raw = LoadResultSchema.parse(await port.load(sessionId, signal)); - const result = - raw.kind === "failed" - ? { - ...raw, - error: failure(raw.error ?? raw.message, { - classification: "persistence", - operation: { id: sessionId, kind: "load", sessionId }, - phase: "load", - }), - } - : raw; - diagnostic("persistence", result.kind === "failed" ? "warning" : "debug", "load.settled", { - sessionId, - outcome: result.kind, - revision: result.kind === "loaded" ? result.revision : undefined, - durationMs: Math.round(performance.now() - startedAt), - ...(result.kind === "failed" ? { reason: result.message, error: result.error } : {}), - ...(result.kind === "loaded" ? { batchCount: result.batches.length } : {}), - }); - return result; - }, - (error) => { - diagnostic("persistence", "warning", "load.failed", { - sessionId, - durationMs: Math.round(performance.now() - startedAt), - error: diagnosticError(error), - }); - const cause = failure(error, { - classification: "persistence", - operation: { id: sessionId, kind: "load", sessionId }, - phase: "load", - }); - return { kind: "failed", message: cause.message, error: cause }; - }, - sessionId, - ); -} -/** - * Loads a session's committed batches through one {@link loadOperation}. Resolves with its result - * and never rejects. - */ -export async function loadSession(port: SessionPersistence, sessionId: SessionId) { - const actor = loadOperation(port, sessionId); - await actor.start(); - return actor.result; -} diff --git a/packages/core-agent/session/session-prompt.test.ts b/packages/core-agent/session/session-prompt.test.ts deleted file mode 100644 index 71732098..00000000 --- a/packages/core-agent/session/session-prompt.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { ActorIdSchema, AgentIdSchema, StepsSchema } from "../agent/types.ts"; -import { defaultPolicy, projectPolicy } from "../policy/policy.ts"; -import { projectSessionPrompt } from "./session-prompt.ts"; -import { createSession, restoreSession } from "./session-runtime.ts"; -import { scriptedCompletion, testOptions } from "./test-support.ts"; - -const turn = { - id: ActorIdSchema.parse("turn"), - generation: 1, - agent: AgentIdSchema.parse("a"), - steps: StepsSchema.parse(1), - messages: [{ role: "user" as const, text: "current" }], - view: { kind: "history" as const }, -}; -test("configured system, ordered session inputs, context, history and current messages", () => { - expect( - projectSessionPrompt( - { - agent: { model: "m", systemPrompt: "configured", tools: [] }, - context: [{ role: "user", text: "context" }], - log: [ - { - agent: turn.agent, - outcome: { kind: "completed" }, - messages: [{ role: "assistant", text: "history" }], - }, - ], - turn, - }, - ["first", "second"], - ).map((message) => message.content), - ).toEqual(["configured", "first", "second", "context", "history", "current"]); -}); -test("a handoff step sees the resolver's packet and later messages, not the predecessor's history", () => { - const policy = defaultPolicy({ agents: [["a", { tools: [] }]] }, 3); - expect( - projectPolicy( - { - agent: { model: "m", systemPrompt: "configured", tools: [] }, - log: [ - { - agent: turn.agent, - outcome: { kind: "completed" }, - messages: [{ role: "assistant", text: "history" }], - }, - ], - turn: { - ...turn, - messages: [ - { role: "user", text: "earlier" }, - { role: "assistant", text: "draft" }, - { role: "user", text: "current" }, - { role: "assistant", text: "review this" }, - { role: "user", text: "after" }, - ], - view: { kind: "handoff", from: AgentIdSchema.parse("writer"), at: 4 }, - }, - }, - ["session"], - policy, - ).messages.map((message) => message.content), - ).toEqual(["configured", "session", "current", "review this", "after"]); -}); -test("an invalid correlation in the projected context rejects", () => { - expect(() => - projectSessionPrompt( - { - agent: { model: "m", tools: [] }, - log: [], - context: [{ role: "tool", callId: "x" as never, text: "orphan" }], - turn, - }, - [], - ), - ).toThrow(); -}); -test("restored sessions project the same next request as an ordinary inherited fork", async () => { - const requests: any[] = []; - const options = testOptions({ - systemInputs: ["ordered"], - complete: scriptedCompletion( - Array.from({ length: 3 }, () => ({ kind: "answer", text: "answer" })), - requests, - ), - }); - const parent = await createSession(options); - await parent.input("one").settled; - const child = await parent.fork(); - const restored = await restoreSession(options, parent.snapshot.durable.conversation.sessionId); - await child.input("two").settled; - await restored.input("two").settled; - expect(requests[1]).toEqual(requests[2]); -}); diff --git a/packages/core-agent/session/session-prompt.ts b/packages/core-agent/session/session-prompt.ts deleted file mode 100644 index d8bfb196..00000000 --- a/packages/core-agent/session/session-prompt.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod"; - -import { ChatMessageSchema } from "../agent/agent.ts"; -import { - projectConversationPrompt, - projectMediaPointers, - type PromptInput, -} from "../agent/prompt.ts"; -import { freeze } from "../fsm/fsm.ts"; - -/** - * Projects a step's prompt as chat messages: the agent's configured system prompt, then the - * standing session instructions in order (not system notices), then the history of - * {@link projectConversationPrompt}, target-aware (see {@link projectMediaPointers}). A handoff - * step's packet comes from the policy's handoff resolver, so its prompt is `projectPolicy`'s, not - * this one. The result is frozen. - * - * @throws Error when the history does not form a valid prompt, such as a tool result without its - * call. - */ -export function projectSessionPrompt(input: PromptInput, systemInputs: readonly string[]) { - const projected = projectConversationPrompt({ - ...input, - agent: { ...input.agent, systemPrompt: undefined }, - }); - const messages = input.target - ? projectMediaPointers(projected, input.target).messages - : projected; - return freeze( - z - .array(ChatMessageSchema) - .parse([ - ...(input.agent.systemPrompt - ? [{ role: "system", content: input.agent.systemPrompt }] - : []), - ...systemInputs.map((content) => ({ role: "system", content })), - ...messages, - ]), - ); -} diff --git a/packages/core-agent/session/session-runtime.test.ts b/packages/core-agent/session/session-runtime.test.ts deleted file mode 100644 index 86f14ad3..00000000 --- a/packages/core-agent/session/session-runtime.test.ts +++ /dev/null @@ -1,636 +0,0 @@ -import { expect, spyOn, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import * as sessionLog from "./session-log.ts"; -import { journalJSONL } from "./session-log.ts"; -import { createSession, defineTool, restoreSession } from "./session-runtime.ts"; -import { - deferred, - lostAcknowledgement, - scriptedCompletion, - testOptions, - until, -} from "./test-support.ts"; -import { createMemoryBacking, createMemoryPersistence } from "./testing/memory-persistence.ts"; - -test("answers, immutable snapshots, system updates and idle restoration without effects", async () => { - const requests: unknown[] = []; - const options = testOptions({ - complete: scriptedCompletion( - [ - { kind: "answer", text: "One" }, - { kind: "answer", text: "Two" }, - ], - requests, - ), - }); - const session = await createSession(options); - const before = session.snapshot; - expect((await session.input("Hi").settled).kind).toBe("terminal"); - expect((await session.updateSystem(["Be brief"])).kind).toBe("accepted"); - await session.input("Again").settled; - const restored = await restoreSession( - { - ...options, - bindings: { - ...options.bindings, - complete: () => { - throw new Error("Unexpected completion"); - }, - }, - }, - session.snapshot.durable.conversation.sessionId, - ); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); - expect(before.durable.conversation.log).toHaveLength(0); - expect(JSON.stringify(requests)).toContain("Be brief"); - expect(journalJSONL(restored.snapshot.durable)).not.toContain("SECRET_SENTINEL"); - expect(journalJSONL(restored.snapshot.durable)).not.toContain("baseUrl"); -}); - -test("tool loop journals each result before batch settlement and completion", async () => { - const session = await createSession( - testOptions({ - complete: scriptedCompletion([ - { - kind: "tools", - text: "Calling", - calls: [{ id: "c1", name: "echo", args: { text: "result" } }], - }, - { kind: "answer", text: "Done" }, - ]), - }), - ); - const result = await session.input("Tool please").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - const bodies = session.snapshot.durable.records.map((record) => record.body); - expect(bodies.filter((body) => body.kind === "tool")).toHaveLength(1); - expect( - session.snapshot.durable.conversation.log[0]!.messages.map((message) => message.role), - ).toEqual(["user", "assistant", "tool", "assistant"]); -}); - -test("handoff, exhausted turns and completion failure", async () => { - const session = await createSession( - testOptions({ steps: 1, complete: () => ({ kind: "handoff", agent: "b", text: "Delegate" }) }), - ); - const result = await session.input("Go").settled; - expect(result.kind === "terminal" && result.record).toMatchObject({ - agent: "b", - outcome: { kind: "exhausted" }, - }); - const failed = await createSession( - testOptions({ - complete: () => { - throw new Error("offline"); - }, - }), - ); - expect(await failed.input("Go").settled).toMatchObject({ - kind: "terminal", - record: { outcome: { kind: "failed", error: { message: "offline" } } }, - }); -}); - -test("barge-in cancels the old completion, correlates waiters and ignores late outcomes", async () => { - const pending = deferred(); - let calls = 0; - const session = await createSession( - testOptions({ - complete: () => (++calls === 1 ? pending.promise : { kind: "answer", text: "new" }), - }), - ); - const first = session.input("first"); - await until(() => calls === 1); - const second = session.input("second"); - const result = await second.settled; - expect(await first.settled).toEqual(result); - pending.resolve({ kind: "answer", text: "late" }); - await Promise.resolve(); - expect( - session.snapshot.durable.conversation.log[0]!.messages.map((message) => message.text), - ).toEqual(["first", "second", "new"]); -}); - -test("active forks capture the exact boundary before queued next input; branches are independent", async () => { - const pending = deferred(); - let calls = 0; - const options = testOptions({ - complete: () => (++calls === 1 ? pending.promise : { kind: "answer", text: "next" }), - }); - const session = await createSession(options); - const first = session.input("first"); - await until(() => calls === 1); - const branch = session.fork(); - expect((await session.updateSystem(["busy"])).kind).toBe("busy"); - pending.resolve({ kind: "answer", text: "first answer" }); - await first.settled; - const next = session.input("next"); - const child = await branch; - expect(child.snapshot.durable.conversation.log).toHaveLength(1); - await next.settled; - await child.updateSystem(["child"]); - expect(session.snapshot.durable.systemInputs).toEqual([]); - const ancestor = journalJSONL(session.snapshot.durable); - const compacted = await child.compact([{ role: "user", text: "summary" }]); - expect(compacted.snapshot.durable.conversation).toMatchObject({ - sequence: 1, - log: [], - context: [{ role: "user", text: "summary" }], - }); - const reset = await compacted.compact([]); - expect([...reset.snapshot.durable.conversation.context]).toEqual([]); - expect(journalJSONL(session.snapshot.durable)).toBe(ancestor); - expect(() => child.compact([{ role: "tool", callId: "orphan", text: "x" }])).toThrow(); -}); - -test("partial tool cancellation persists results and ignores late effects", async () => { - const slow = deferred(); - let started = 0; - const options = testOptions({ - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => { - started++; - return text === "slow" ? slow.promise : text; - }, - }), - ], - ]), - complete: () => ({ - kind: "tools", - text: "work", - calls: [ - { id: "fast", name: "echo", args: { text: "fast" } }, - { id: "slow", name: "echo", args: { text: "slow" } }, - ], - }), - }); - const session = await createSession(options); - const turn = session.input("Go"); - await until(() => started === 2 && session.snapshot.durable.partial.length === 1); - await session.fire({ type: "abort" }); - expect(await turn.settled).toMatchObject({ record: { outcome: { kind: "aborted" } } }); - const snapshot = session.snapshot.durable; - expect( - snapshot.conversation.log[0]!.messages.filter((m) => m.role === "tool").map((m) => m.text), - ).toEqual(["fast"]); - slow.resolve("late"); - await Promise.resolve(); - expect(session.snapshot.durable).toEqual(snapshot); -}); - -test("rejected input append starts no external work; lost receipt dispatches exactly once", async () => { - const base = createMemoryPersistence(); - let calls = 0; - const rejected = await createSession( - testOptions({ - complete: () => { - calls++; - return { kind: "answer", text: "x" }; - }, - persistence: { - lifetime: base.lifetime, - putBlob: base.putBlob.bind(base), - getBlob: base.getBlob.bind(base), - load: base.load, - getConfig: base.getConfig.bind(base), - putConfig: base.putConfig.bind(base), - append: (request, signal) => - request.expectedRevision > 0 - ? Promise.resolve({ kind: "rejected", message: "offline" }) - : base.append(request, signal), - }, - }), - ); - expect((await rejected.input("Go").accepted).kind).toBe("failed"); - expect(calls).toBe(0); - const session = await createSession( - testOptions({ - persistence: lostAcknowledgement(createMemoryPersistence(), (records) => - records.some((record) => record.includes('"type":"user"')), - ), - complete: () => { - calls++; - return { kind: "answer", text: "x" }; - }, - }), - ); - await session.input("Go").settled; - expect(calls).toBe(1); - expect( - session.snapshot.durable.records.filter( - (record) => record.body.kind === "event" && record.body.event.type === "user", - ), - ).toHaveLength(1); -}); - -test("fresh adapter restores interrupted partial tools, records one recovery, never replays", async () => { - const backing = createMemoryBacking(); - const slow = deferred(); - const options = testOptions({ - persistence: createMemoryPersistence(backing), - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => (text === "slow" ? slow.promise : text), - }), - ], - ]), - complete: () => ({ - kind: "tools", - text: "work", - calls: [ - { id: "fast", name: "echo", args: { text: "fast" } }, - { id: "slow", name: "echo", args: { text: "slow" } }, - ], - }), - }); - const session = await createSession(options); - session.input("Go"); - await until(() => session.snapshot.durable.partial.length === 1); - const sessionId = session.snapshot.durable.conversation.sessionId; - await session.close(); - const restoreOptions = { - ...options, - persistence: lostAcknowledgement(createMemoryPersistence(backing), (records) => - records.some((record) => record.includes('"kind":"recovery"')), - ), - complete: () => { - throw new Error("MUST NOT RUN"); - }, - }; - const restored = await restoreSession(restoreOptions, sessionId); - expect(restored.snapshot.durable.conversation.log[0]!).toMatchObject({ - outcome: { kind: "failed" }, - }); - expect( - restored.snapshot.durable.conversation.log[0]!.messages.filter((m) => m.role === "tool"), - ).toHaveLength(1); - expect( - restored.snapshot.durable.records.filter((record) => record.body.kind === "recovery"), - ).toHaveLength(1); - expect((await restoreSession(restoreOptions, sessionId)).snapshot.durable).toEqual( - restored.snapshot.durable, - ); -}); - -test("interrupted recovery keeps a committed tool result's parts, not just its text", async () => { - const backing = createMemoryBacking(); - const slow = deferred(); - const options = testOptions({ - persistence: createMemoryPersistence(backing), - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => - text === "slow" - ? slow.promise - : { - text: "fast", - parts: [ - { type: "text", text: "fast" }, - { type: "blob", bytes: new Uint8Array([1, 2, 3]), media: "image/png" }, - ], - }, - }), - ], - ]), - complete: () => ({ - kind: "tools", - text: "work", - calls: [ - { id: "fast", name: "echo", args: { text: "fast" } }, - { id: "slow", name: "echo", args: { text: "slow" } }, - ], - }), - }); - const session = await createSession(options); - session.input("Go"); - await until(() => session.snapshot.durable.partial.length === 1); - const sessionId = session.snapshot.durable.conversation.sessionId; - await session.close(); - const restoreOptions = { - ...options, - persistence: createMemoryPersistence(backing), - complete: () => { - throw new Error("MUST NOT RUN"); - }, - }; - const restored = await restoreSession(restoreOptions, sessionId); - const toolMessages = restored.snapshot.durable.conversation.log[0]!.messages.filter( - (m) => m.role === "tool", - ); - expect(toolMessages).toHaveLength(1); - expect(toolMessages[0]).toMatchObject({ - callId: "fast", - text: "fast", - parts: [ - { type: "text", text: "fast" }, - { type: "blob", ref: expect.objectContaining({ media: "image/png", bytes: 3 }) }, - ], - }); -}); - -function textPartTool(text: string) { - return defineTool({ - input: z.object({ text: z.string() }), - run: () => ({ - text: "see attachment", - parts: [ - { type: "text", text: "see attachment" }, - { type: "blob", bytes: new TextEncoder().encode(text), media: "text/plain" }, - ], - }), - }); -} - -test("a tool's text-media part reaches the next request as its own line of text", async () => { - const requests: { messages: readonly { role: string; content?: unknown }[] }[] = []; - const session = await createSession( - testOptions({ - tools: new Map([["echo", textPartTool("SECRET-TOOL-TEXT")]]), - complete: (request) => { - requests.push(request); - return requests.length === 1 - ? { kind: "tools", text: "", calls: [{ id: "c1", name: "echo", args: { text: "x" } }] } - : { kind: "answer", text: "done" }; - }, - }), - ); - session.input("Go"); - await until(() => session.snapshot.durable.conversation.log.length === 1); - const tool = requests[1]?.messages.find((message) => message.role === "tool"); - expect(tool?.content).toBe("see attachment\nSECRET-TOOL-TEXT"); - expect(JSON.stringify(requests[1]?.messages)).not.toContain('"blob"'); - await session.close(); -}); - -test("abort overtaking a tool outcome does not accept a result into a settled batch", async () => { - const port = createMemoryPersistence(); - const releaseAbort = deferred(); - const tool = deferred(); - let started = false; - let abortWriting = false; - const session = await createSession( - testOptions({ - persistence: { - ...port, - async append(request, signal) { - const result = await port.append(request, signal); - if (request.records.some((record) => JSON.parse(record).body.event?.type === "abort")) { - abortWriting = true; - await releaseAbort.promise; - } - return result; - }, - }, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: () => { - started = true; - return tool.promise; - }, - }), - ], - ]), - complete: () => ({ - kind: "tools", - text: "work", - calls: [{ id: "c", name: "echo", args: { text: "x" } }], - }), - }), - ); - const turn = session.input("Go"); - await until(() => started); - const abort = session.fire({ type: "abort" }); - await until(() => abortWriting); - tool.resolve("too late"); - await until(() => session.snapshot.queue.some((submission) => submission.input.kind === "tool")); - releaseAbort.resolve(); - await abort; - expect(await turn.settled).toMatchObject({ record: { outcome: { kind: "aborted" } } }); - expect( - session.snapshot.durable.conversation.log[0]!.messages.filter( - (message) => message.role === "tool", - ), - ).toHaveLength(0); - expect(session.snapshot.status).toBe("ready"); -}); - -test("captured registries survive caller mutation", async () => { - const options = testOptions(); - const session = await createSession(options); - (options.configuration.agents as Map).clear(); - (options.bindings.tools as Map).clear(); - expect(await session.input("Go").settled).toMatchObject({ - record: { outcome: { kind: "completed" } }, - }); - await session.close(); -}); - -test("closing a parent settles a fork whose child initialization receipt is delayed", async () => { - const port = createMemoryPersistence(); - const release = deferred(); - let initializing = false; - let root = ""; - const session = await createSession( - testOptions({ - persistence: { - ...port, - async append(request, signal) { - const result = await port.append(request, signal); - if (root && request.sessionId !== root) { - initializing = true; - await release.promise; - } - return result; - }, - }, - }), - ); - root = session.snapshot.durable.conversation.sessionId; - const rejected = session.fork().then( - () => "published", - (error) => String(error), - ); - await until(() => initializing); - await session.close(); - expect(await rejected).toContain("closed"); - release.resolve(); -}); - -for (const boundary of ["input", "tool-intent"] as const) { - test(`recovery after ${boundary} commit but before acknowledgement dispatches no dependent work`, async () => { - const backing = createMemoryBacking(); - const port = createMemoryPersistence(backing); - const release = deferred(); - let interrupted = false; - let completions = 0; - let tools = 0; - const options = testOptions({ - persistence: { - ...port, - async append(request, signal) { - const result = await port.append(request, signal); - const matched = request.records.some((serialized) => { - const body = JSON.parse(serialized).body; - return boundary === "input" - ? body.event?.type === "user" - : body.event?.event?.type === "model_settled"; - }); - if (matched) { - interrupted = true; - await release.promise; - return { kind: "indeterminate", message: "Lost receipt" }; - } - return result; - }, - }, - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: () => { - tools++; - return "unexpected"; - }, - }), - ], - ]), - complete: () => { - completions++; - return { - kind: "tools", - text: "work", - calls: [{ id: "c", name: "echo", args: { text: "x" } }], - }; - }, - }); - const session = await createSession(options); - const turn = session.input("Go"); - await until(() => interrupted); - await session.close(); - const restored = await restoreSession( - { - ...options, - persistence: createMemoryPersistence(backing), - bindings: { - ...options.bindings, - complete: () => { - throw new Error("No replay"); - }, - }, - }, - session.snapshot.durable.conversation.sessionId, - ); - expect(restored.snapshot.durable.conversation.log[0]!.outcome.kind).toBe("failed"); - expect(restored.snapshot.durable.conversation.log[0]!.messages[0]?.text).toBe("Go"); - expect(completions).toBe(boundary === "tool-intent" ? 1 : 0); - expect(tools).toBe(0); - expect((await turn.settled).kind).toBe("closed"); - release.resolve(); - }); -} - -test("idle system changes and queued user inputs capture the committed system version", async () => { - const port = createMemoryPersistence(); - const release = deferred(); - let updating = false; - const requests: any[] = []; - const session = await createSession( - testOptions({ - persistence: { - ...port, - async append(request, signal) { - const result = await port.append(request, signal); - if (request.records.some((record) => JSON.parse(record).body.kind === "system")) { - updating = true; - await release.promise; - } - return result; - }, - }, - complete: scriptedCompletion([{ kind: "answer", text: "done" }], requests), - }), - ); - const update = session.updateSystem(["queued instruction"]); - await until(() => updating); - const turn = session.input("Go"); - release.resolve(); - await update; - expect(await turn.settled).toMatchObject({ record: { outcome: { kind: "completed" } } }); - expect(requests[0].messages[1]).toEqual({ role: "system", content: "queued instruction" }); -}); - -test("fork after partial-tool abort preserves source evidence and valid child prompts", async () => { - const slow = deferred(); - const requests: any[] = []; - const session = await createSession( - testOptions({ - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => (text === "slow" ? slow.promise : text), - }), - ], - ]), - complete: scriptedCompletion( - [ - { - kind: "tools", - text: "work", - calls: [ - { id: "fast", name: "echo", args: { text: "fast" } }, - { id: "slow", name: "echo", args: { text: "slow" } }, - ], - }, - { kind: "answer", text: "child answer" }, - ], - requests, - ), - }), - ); - const turn = session.input("Go"); - await until(() => session.snapshot.durable.partial.length === 1); - const branch = session.fork(); - await session.fire({ type: "abort" }); - await turn.settled; - const child = await branch; - const ancestor = journalJSONL(session.snapshot.durable); - await child.input("Continue").settled; - const callMessage = requests[1].messages.find((message: any) => message.tool_calls); - expect(callMessage.tool_calls.map((call: any) => call.id)).toEqual(["fast"]); - expect(journalJSONL(session.snapshot.durable)).toBe(ancestor); - slow.resolve("late"); -}); - -test("synchronous fork publication failure rejects its caller without poisoning later forks", async () => { - const session = await createSession(testOptions()); - const seed = spyOn(sessionLog, "toSeed").mockImplementationOnce(() => { - throw new Error("Seed construction failed"); - }); - try { - await expect(session.fork()).rejects.toThrow("Seed construction failed"); - expect(session.snapshot.status).toBe("ready"); - } finally { - seed.mockRestore(); - } - const child = await session.fork(); - expect(child.snapshot.durable.conversation.origin.kind).toBe("fork"); - await child.close(); - await session.close(); -}); diff --git a/packages/core-agent/session/session-runtime.ts b/packages/core-agent/session/session-runtime.ts deleted file mode 100644 index d4941569..00000000 --- a/packages/core-agent/session/session-runtime.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { AgentDefinition, Tool } from "../agent/agent-runtime.ts"; -import type { ActorId, Failure, TurnRecord } from "../agent/types.ts"; -import type { EffectEmitter } from "../effects/index.ts"; -import type { StreamUpdateSink, ToolUpdateSink } from "../host/host.ts"; -import type { CompletionPort, PermissionPort } from "../host/ports.ts"; -import type { Policy, PolicyPatch, PolicyResolvers } from "../policy/policy.ts"; -import type { ProviderBindings, ResolvedModel } from "../providers/transport.ts"; -import type { EnvEvent, EnvEventSchema } from "./events.ts"; -import type { SessionPersistence } from "./persistence.ts"; -import { createSession, restoreSession, SessionNotFoundError } from "./runtime/open.ts"; -import type { CommandReceipt, SessionState } from "./session-fsm.ts"; -import type { LastCompletionUsage } from "./session-log.ts"; - -export { createSession, restoreSession, SessionNotFoundError }; - -export { defineTool } from "../host/ports.ts"; - -export type { Tool, AgentDefinition }; - -export type { - HostToolNotification, - ToolUpdateSink, - HostStreamNotification, - StreamUpdateSink, -} from "../host/host.ts"; - -export type { PermissionPort, PermissionRequest, ToolKind, ToolLocation } from "../host/ports.ts"; - -/** - * What a session may do: its agents, step allowance, standing instructions and starting policy. - * The agents and the parameter schemas of the bound tools form the registry that is journaled. - */ -export type SessionConfiguration = Readonly<{ - /** - * Agent the first turn runs as; must be a key of `agents`. On restore it is the agent a - * conversation switches to when its saved current agent is no longer registered. - */ - agent: string; - /** Agent definitions by ID. Journaled as part of the registry. */ - agents: ReadonlyMap; - /** Model steps each turn may use. A `policy.steps` value overrides it. */ - steps: number; - /** - * Standing session instructions for a new session ("system" here means these instructions, not a - * system notice). Ignored by {@link restoreSession}, which keeps the journaled ones; change them - * with {@link SessionRuntime.updateSystem}. - */ - systemInputs?: readonly string[]; - /** - * Patch applied to the default policy of a new session. On restore it supplies the defaults a - * saved setting falls back to when the live bindings reject it, and the tool permissions of agents - * the saved policy does not list. - */ - policy?: PolicyPatch; -}>; - -/** - * How a session does its work: completion, tools, permission UI and observers. Bindings are never - * journaled; they are captured when the session is created or restored, and mutating the original - * maps later does not reconfigure an open session. - */ -export type SessionBindings = Readonly<{ - /** A single completion port. Supply exactly one of `complete` and `providers`. */ - complete?: CompletionPort; - /** - * Provider registry that resolves `policy.provider`/`policy.model` to a wire model and profile. - * Supply exactly one of `complete` and `providers`; a new provider-bound session needs a - * `policy.provider`. - */ - providers?: ProviderBindings; - /** Tool implementations by name. Their parameter schemas are journaled as part of the registry. */ - tools?: ReadonlyMap; - /** - * Policy packs, prompt projections and handoff projections, replacing the built-in set (built-in - * packs apply when `packs` is omitted). Its provider and permission fields are ignored; they are - * derived from `providers` and `requestPermission`. - */ - policies?: PolicyResolvers; - /** - * Generates request IDs (which become append IDs), the session ID when `sessionId` is omitted, - * and fork/compaction request and child session IDs. Defaults to `crypto.randomUUID`. - */ - id?: () => string; - /** - * Called with every new session snapshot, including uncommitted `pending` states. Its return - * value, thrown errors and rejections are ignored; observation cannot change execution. - */ - observe?: (snapshot: SessionState) => unknown; - /** Display-only tool progress; not a permission request and not a durable result. */ - toolUpdate?: ToolUpdateSink; - /** Display-only progress of a completion (status and stream deltas); not committed history. */ - streamUpdate?: StreamUpdateSink; - /** Asks the user to approve tool calls. Required for `permissions: "ask"`. */ - requestPermission?: PermissionPort; - /** - * Subscriber for this session's effect events (child lifecycle, command dispatch, tool - * admission, permission decisions, completion usage). Always combined with the default - * diagnostics subscriber, never replacing it; omit to run with logging only. - */ - effects?: EffectEmitter; -}>; - -/** Everything {@link createSession} and {@link restoreSession} need to open a session. */ -export type SessionOptions = Readonly<{ - /** Journal and blob store. The session never closes it; its lifetime belongs to the caller. */ - persistence: SessionPersistence; - configuration: SessionConfiguration; - bindings: SessionBindings; - /** - * ID for a new session; generated with `bindings.id` when omitted. {@link restoreSession} ignores - * it and uses its own argument. - */ - sessionId?: string; -}>; - -/** - * How a turn submitted by user input ended, as seen by the caller of {@link SessionRuntime.input}. - * Only `terminal` means a turn outcome was committed. - */ -export type TerminalResult = - /** - * The turn's terminal record committed. `record.outcome` may still be failed, aborted or - * exhausted; `turnId` is the turn the input started, joined or was queued for. - */ - | Readonly<{ kind: "terminal"; turnId: ActorId; record: TurnRecord }> - /** - * No outcome was committed for this input: its input receipt was not `accepted`, or the session - * failed, for example on a storage error, before the turn ended. A `failed` receipt passes on its - * own error; other receipt kinds, including `closed` for input submitted after close, become an - * `admission` failure named after the receipt kind. - */ - | Readonly<{ kind: "failed"; message: string; error: Failure }> - /** The session was closed before the turn ended. */ - | Readonly<{ kind: "closed"; message: string }>; - -/** - * Result of the `accepted` promise of a {@link SessionRuntime.dispatch} handle: the input receipt, - * or `close_acknowledged` once a `close` event has been processed. - * - * Input receipt kinds: `accepted` means the event's records committed to the journal at - * `receipt.revision`; `busy` means a system or policy change arrived while a turn was active or - * staged, or queued inputs were waiting; `failed` means staging rejected the event (for example the - * mid-turn input policy refused it) or the session has failed, with the structured `error`; - * `closed` means the session was closed. `ignored` is reserved for internal records whose target no - * longer exists and is not returned for the public events. - */ -export type EnvReceipt = CommandReceipt | Readonly<{ kind: "close_acknowledged" }>; - -/** - * Result of the `settled` promise of a {@link SessionRuntime.dispatch} handle. The variant depends - * on the event type. - */ -export type EnvSettlement = - /** For `user` events: how the turn the input started, joined or was queued for ended. */ - | TerminalResult - /** - * For `fork` and `compact`: the child session (not a child operation) was published, i.e. its - * blobs were copied and its creation record committed. The caller owns closing it. - */ - | Readonly<{ kind: "branch"; session: SessionRuntime }> - /** - * For `system`, `policy`, `abort` and `close`: the same receipt as `accepted`. Nothing further is - * awaited; for `abort`, the aborted turn's terminal record may commit later. - */ - | Readonly<{ kind: "acknowledged"; receipt: EnvReceipt }>; - -/** - * The two observations of a dispatched event. `accepted` resolves with the input receipt once the - * event's journal append commits or the event is refused; `settled` resolves later with the - * event's end result ({@link EnvSettlement}). Failures are reported as result kinds; inspect the - * discriminant rather than relying on resolution. - */ -export type EnvCommandHandle = Readonly<{ - accepted: Promise; - settled: Promise; -}>; - -/** - * Relationship between the persisted tool/agent registry and the live bindings. Any difference is - * journaled as a `configuration` record ahead of the next new work; history is never rewritten. - */ -export type SessionRegistry = - /** The journaled registry and policy are the ones the session runs with. */ - | Readonly<{ kind: "current" }> - /** - * A restored session whose saved registry, policy or current agent differs from the live - * bindings. The first `user`, `system`, `policy`, `fork` or `compact` event commits a - * `configuration` record before that work is staged; this state lasts until that record commits - * (and for good when its append fails, since the session then fails). `differences` are readable - * summaries such as `missing tools.read_file` or `changed policy.model`. - */ - | Readonly<{ kind: "pending_adoption"; differences: readonly string[] }>; - -/** - * An open session: journals every input before running it and runs turns through the host. Created - * by {@link createSession}, {@link restoreSession}, {@link SessionRuntime.fork} and - * {@link SessionRuntime.compact}. - * - * Public events are validated synchronously: a malformed event makes `dispatch`, `fire`, `input`, - * `updateSystem` and `updatePolicy` throw before anything is submitted. After that, outcomes are - * reported through receipt and settlement kinds. - */ -export type SessionRuntime = { - /** - * Current state of the session actor. `durable` is committed state; `pending.next` (while - * `committing` or `reconciling`) is a staged proposal waiting for storage and must not be treated - * as committed. `closed` is final; `failed` can only move to `closed`. - */ - readonly snapshot: SessionState; - /** - * Provider, model, wire model, profile and capabilities the next turn resolves to under - * {@link SessionRuntime.policy}. Undefined when the policy names no provider or the session is - * bound through `bindings.complete`. - */ - readonly model?: ResolvedModel; - /** - * Usage accounting of the latest committed completion that reported it, with its turn and - * operation IDs. It describes one observed response, not the current context size or a cumulative - * cost, and is rebuilt on restore without calling the provider. - */ - readonly lastCompletionUsage?: LastCompletionUsage; - /** Whether the live registry has been journaled; see {@link SessionRegistry}. */ - readonly registry: SessionRegistry; - /** - * Configuration in force: the policy the transcript ran under and a running turn uses. A pending - * registry adoption's reconciled policy, else the durable one. - */ - readonly policy?: Policy; - /** - * Configuration the next turn runs under: a pending selection (or one being applied), else - * {@link SessionRuntime.policy}. Ahead of `policy` while a turn runs with an older selection. - */ - readonly selectedPolicy?: Policy; - /** Dispatches `event` and returns only its `accepted` promise. */ - fire(event: unknown): Promise; - /** - * Submits a public event ({@link EnvEvent}) and returns its accepted and settled promises. Every - * event except `abort` and `close` first triggers a pending registry adoption. - * @throws when `event` does not match {@link EnvEventSchema}. - */ - dispatch(event: unknown): EnvCommandHandle; - /** - * Submits user input: text, attachments already stored with `persistence.putBlob`, or both. - * `accepted` resolves with the input receipt once the input is committed (or refused). `settled` - * resolves when the turn the input started ends; with barge-in the input joins the active turn, - * and a queued input settles when the turn it starts after dequeue ends. - * @throws when the input has neither text nor attachments or is otherwise malformed. - */ - input(input: string | Omit, "type">): { - accepted: Promise; - settled: Promise; - }; - /** - * Replaces the standing session instructions (not a system notice) with a new system version, - * committed to the journal and used from the next turn. Resolves with `busy` while a turn is - * active or queued inputs are waiting. - */ - updateSystem(inputs: readonly string[]): Promise; - /** - * Selects `patch` applied to {@link SessionRuntime.selectedPolicy}, validated against the live - * bindings, and stores it in the persistence port's configuration store at once, at any time. - * Never resolves `busy`. The selection applies as a "configuration applied" record at the next - * boundary between turns, before any queued input starts; a running turn keeps its settings. - * Resolves `accepted` when the conversation was idle and the record committed, `selected` when a - * turn is running or starting, `ignored` when the selection matches the policy in force, and - * `failed` when the patch is rejected or cannot be stored. Applying a changed `permissions` - * value or a change of allowed tools revokes remembered `allow-session` approvals. - * @throws when `patch` does not match the policy patch schema. - */ - updatePolicy(patch: PolicyPatch): Promise; - /** - * Creates an independent child session (not a child operation) with this session's history. The - * request is journaled, then waits for the active turn's terminal record; resolves once the child - * is published. The parent is unchanged and no live work moves to the child. - * @throws (rejects) when the request is not accepted, publication fails, or the parent closes or - * fails first. - */ - fork(): Promise; - /** - * Creates a child session whose context is `context` (complete tool exchanges only) and whose - * turn log is empty; no model is asked to summarize. Otherwise behaves like - * {@link SessionRuntime.fork}. - * @throws synchronously when `context` is not valid session context. - */ - compact(context: unknown): Promise; - /** - * Closes the session: cancels running operations and storage calls, and settles every waiting - * caller with `closed`. Later submissions receive `closed` receipts. The persistence store stays - * open, and an append already dispatched may still commit; restore to learn its result. - */ - close(): Promise; -}; diff --git a/packages/core-agent/session/settlement-failure.test.ts b/packages/core-agent/session/settlement-failure.test.ts deleted file mode 100644 index a09f0f67..00000000 --- a/packages/core-agent/session/settlement-failure.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { failure } from "../agent/types.ts"; -import { createProviderCapture } from "../environment/provider-capture.ts"; -import { withFixtureDiagnostics } from "../logging/fixture-capture.ts"; -import { createSession } from "./index.ts"; -import { RevisionSchema } from "./persistence.ts"; -import { testOptions } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -for (const mode of ["rejected", "conflict", "load-failure", "uncertain"] as const) { - test(`public settlement preserves ${mode} storage identity and cause without journal inspection`, async () => { - const capture = await createProviderCapture(".session-artifacts/settlement-failure"); - await withFixtureDiagnostics(capture.directory, { runId: capture.runId, mode }, async () => { - const port = createMemoryPersistence(); - let completions = 0; - let attempts = 0; - let failedAppendId = ""; - const session = await createSession( - testOptions({ - complete: () => { - completions++; - return { kind: "answer", text: "Unexpected" }; - }, - persistence: { - ...port, - async append(request, signal) { - if (request.expectedRevision === 0) return port.append(request, signal); - attempts++; - failedAppendId = request.appendId; - if (mode === "conflict") - return { kind: "conflict", revision: RevisionSchema.parse(99) }; - const cause = Object.assign( - new Error("Disk refused input journal append", { - cause: new Error("Volume quota exceeded"), - }), - { code: "EDQUOT", path: "/session-store/journal" }, - ); - if (mode === "rejected") - return { kind: "rejected", message: cause.message, error: failure(cause) }; - throw cause; - }, - async load(id, signal) { - if (mode === "load-failure") - throw Object.assign(new Error("Cannot read journal for reconciliation"), { - code: "EIO", - }); - return port.load(id, signal); - }, - }, - }), - ); - try { - const command = session.input("Start review"); - const receipt = await command.accepted; - const result = await command.settled; - expect(result.kind).toBe("failed"); - if (result.kind !== "failed") throw new Error("Expected failure"); - expect(receipt).toMatchObject({ kind: "failed", error: result.error }); - expect(result.error).toMatchObject({ - classification: "persistence", - operation: { - sessionId: session.snapshot.durable.conversation.sessionId, - kind: mode === "load-failure" ? "load" : "append", - }, - }); - if (mode === "load-failure") { - expect(result.error.cause).toMatchObject({ code: "EIO" }); - expect(result.error.details).toMatchObject({ - appendId: failedAppendId, - precedingFailure: { cause: { code: "EDQUOT" } }, - }); - } else { - expect(result.error.operation?.id).toBe(failedAppendId); - if (mode === "rejected") - expect(result.error.cause).toMatchObject({ - code: "EDQUOT", - cause: { message: "Volume quota exceeded" }, - }); - if (mode === "conflict") - expect(result.error.details).toMatchObject({ observed: { actualRevision: 99 } }); - if (mode === "uncertain") - expect(result.error.details).toMatchObject({ - attempts: 2, - precedingFailure: { cause: { code: "EDQUOT" } }, - }); - } - expect(completions).toBe(0); - expect(attempts).toBe(mode === "uncertain" ? 2 : 1); - expect(JSON.parse(JSON.stringify(result))).toEqual(result); - expect(await session.input("Cannot continue").settled).toMatchObject({ - error: result.error, - }); - await Bun.write(`${capture.directory}/settlement.json`, JSON.stringify(result, null, 2)); - } finally { - await session.close(); - } - }); - const records = (await Bun.file(`${capture.directory}/diagnostics.jsonl`).text()) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect( - records.some( - (record) => - record.event === "submission.receipt" && - record.level === "error" && - record.error.operation, - ), - ).toBe(true); - }); -} - -test("admission failure identifies the rejected input and leaves the session usable", async () => { - const session = await createSession(testOptions()); - try { - const receipt = await session.updatePolicy({ project: "missing@1" }); - expect(receipt).toMatchObject({ - kind: "failed", - error: { - classification: "admission", - operation: { kind: "admission" }, - phase: "stage", - details: { inputKind: "policy" }, - }, - }); - expect(session.snapshot.status).toBe("ready"); - expect(await session.input("Continue").settled).toMatchObject({ kind: "terminal" }); - } finally { - await session.close(); - } -}); diff --git a/packages/core-agent/session/streaming-runtime.test.ts b/packages/core-agent/session/streaming-runtime.test.ts deleted file mode 100644 index 586c43e4..00000000 --- a/packages/core-agent/session/streaming-runtime.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import type { HostStreamNotification } from "../host/host.ts"; -import { openaiChat, type CompletionProfile } from "../providers/index.ts"; -import { - sse, - streamingProfiles, - streamResponse, - streamVector, - text, -} from "../providers/testing/stream-vectors.ts"; -import { journalJSONL } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deferred, deterministicIds, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -function setup(profile: CompletionProfile) { - const updates: HostStreamNotification[] = []; - const requests: any[] = []; - let tools = 0; - const options: SessionOptions = { - persistence: createMemoryPersistence(), - configuration: { - agent: "a", - agents: new Map([["a", { model: "m", tools: ["echo"], successors: [] }]]), - steps: 5, - policy: { - thinkingBudgetTokens: profile.capabilities.thinking.mode === "budget" ? 1024 : null, - provider: profile.id, - stream: true, - thinking: - profile.id.startsWith("anthropic") || profile.id.startsWith("google") ? "budget" : "high", - maxOutputTokens: 2048, - }, - }, - bindings: { - id: deterministicIds(), - tools: new Map([ - [ - "echo", - defineTool({ - input: z.object({ text: z.string() }), - run: ({ text }) => { - tools++; - return text; - }, - }), - ], - ]), - streamUpdate: (event) => { - updates.push(event); - }, - providers: new Map([ - [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async (_url, init) => { - const i = requests.length; - requests.push(JSON.parse(String(init?.body))); - return streamResponse(streamVector(profile, i < 2, `signature-${i}`)); - }) as typeof fetch, - }, - }, - ], - ]), - }, - }; - return { options, updates, requests, tools: () => tools }; -} -for (const profile of streamingProfiles) { - test(`${profile.id}: two streaming tool rounds, one settlement per completion, restore/fork preserve owners`, async () => { - const { options, updates, requests, tools } = setup(profile); - const session = await createSession(options); - const result = await session.input("Go").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(tools()).toBe(2); - expect(requests).toHaveLength(3); - const ids = [...new Set(updates.map((event) => event.completionId))]; - expect(ids).toHaveLength(3); - for (const id of ids) { - const group = updates.filter((event) => event.completionId === id); - expect(group.flatMap((event) => (event.status ? [event.status] : []))).toEqual([ - "pending", - "in_progress", - "completed", - ]); - expect(group.map((event) => event.text ?? "").join("")).toBe(text); - } - const durable = session.snapshot.durable; - const settlements = durable.records.filter( - (record) => - record.body.kind === "event" && - record.body.event.type === "child" && - record.body.event.event.type === "model_settled", - ); - expect(settlements).toHaveLength(3); - expect(journalJSONL(durable)).not.toContain('"completion_update"'); - if (profile.id !== "openai-chat@2") { - expect(durable.continuations).toHaveLength(3); - expect(durable.continuations?.every((entry) => entry.provider === profile.id)).toBe(true); - expect(JSON.stringify(requests[2])).toContain("signature-0"); - expect(JSON.stringify(requests[2])).toContain("signature-1"); - } - const count = updates.length; - const restored = await restoreSession(options, durable.conversation.sessionId); - expect(updates).toHaveLength(count); - const fork = await session.fork(); - await fork.input("Next").settled; - expect(updates.at(-1)?.sessionId).toBe(fork.snapshot.durable.conversation.sessionId); - expect(updates.at(-1)?.status).toBe("completed"); - await Promise.all([session, restored, fork].map((runtime) => runtime.close())); - }); - test(`${profile.id}: incomplete stream journals only failure and never releases partial tool calls`, async () => { - const { options, updates, tools } = setup(profile); - const binding = options.bindings.providers!.get(profile.id)!; - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - providers: new Map([ - [ - profile.id, - { - ...binding, - transport: { - ...binding.transport, - fetch: (async () => - streamResponse( - streamVector(profile, true).slice(0, -1), - )) as unknown as typeof fetch, - }, - }, - ], - ]), - }, - }); - const result = await session.input("Go").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("failed"); - expect(tools()).toBe(0); - expect(session.snapshot.durable.continuations ?? []).toEqual([]); - const settlements = session.snapshot.durable.records.filter( - (record) => - record.body.kind === "event" && - record.body.event.type === "child" && - record.body.event.event.type === "model_settled", - ); - expect(settlements).toHaveLength(1); - expect(settlements[0]).toMatchObject({ - body: { event: { event: { result: { kind: "failed" } } } }, - }); - expect(updates.at(-1)?.status).toBe("failed"); - expect(updates.some((event) => event.status === "completed")).toBe(false); - await session.close(); - }); -} - -test("streamed completion remains non-authoritative while the settlement append is held", async () => { - const { options, updates, tools } = setup(streamingProfiles[0]!); - const gate = deferred(); - let waiting = false; - const session = await createSession({ - ...options, - persistence: { - ...options.persistence, - append: async (request, signal) => { - if ( - !waiting && - request.records.some((raw) => JSON.parse(raw).body.event?.event?.type === "model_settled") - ) { - waiting = true; - await gate.promise; - } - return options.persistence.append(request, signal); - }, - }, - }); - const pending = session.input("Go").settled; - await until(() => waiting); - expect(updates.at(-1)?.status).toBe("completed"); - expect(tools()).toBe(0); - expect( - session.snapshot.durable.records.some( - (record) => - record.body.kind === "event" && - record.body.event.type === "child" && - record.body.event.event.type === "model_settled", - ), - ).toBe(false); - gate.resolve(); - await pending; - expect(tools()).toBe(2); - await session.close(); -}); - -test("barge-in cancels the completion reader; no late stream bytes or partial completion survive", async () => { - const profile = streamingProfiles[0]!; - const { options, updates } = setup(profile); - let cancelled = false; - let requests = 0; - const binding = options.bindings.providers!.get(profile.id)!; - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - providers: new Map([ - [ - profile.id, - { - ...binding, - transport: { - ...binding.transport, - fetch: (async () => { - if (requests++) return streamResponse(streamVector(profile)); - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode(sse(streamVector(profile).slice(0, 1))), - ); - }, - cancel() { - cancelled = true; - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ); - }) as unknown as typeof fetch, - }, - }, - ], - ]), - }, - }); - const first = session.input("Old").settled; - // The first streamed chunk may be thinking (openai-chat reasoning_content) rather than answer text. - await until(() => updates.some((event) => event.text || event.thinking)); - const oldId = updates[0]!.completionId; - const second = session.input("New").settled; - const [a, b] = await Promise.all([first, second]); - expect(a).toEqual(b); - expect(b.kind === "terminal" && b.record.outcome.kind).toBe("completed"); - expect(cancelled).toBe(true); - expect(updates.filter((event) => event.completionId === oldId).at(-1)?.status).toBe("failed"); - const log = session.snapshot.durable.conversation.log; - expect(log[0]?.messages.map((message) => message.text)).toEqual(["Old", "New", text]); - await session.close(); -}); - -test("policy rejects stream:true for unsupported profiles and provider switches need stream:false", async () => { - const { options } = setup(streamingProfiles[0]!); - const registry = new Map(options.bindings.providers); - let oldFetches = 0; - registry.set(openaiChat.id, { - profile: openaiChat, - transport: { - baseUrl: "https://example.invalid", - fetch: (async () => { - oldFetches++; - return Response.json({}); - }) as unknown as typeof fetch, - }, - }); - await expect( - createSession({ - ...options, - configuration: { - ...options.configuration, - policy: { maxOutputTokens: 16384, provider: openaiChat.id, stream: true }, - }, - bindings: { ...options.bindings, providers: registry }, - }), - ).rejects.toThrow("Unsupported streaming"); - const session = await createSession({ - ...options, - bindings: { ...options.bindings, providers: registry }, - }); - expect((await session.updatePolicy({ provider: openaiChat.id })).kind).toBe("failed"); - expect((await session.updatePolicy({ provider: openaiChat.id, stream: false })).kind).toBe( - "accepted", - ); - expect(oldFetches).toBe(0); - await session.close(); -}); - -test("large streamed thought signatures use continuation blobs and observers remain optional", async () => { - const profile = streamingProfiles[2]!; - const { options } = setup(profile); - const signature = "s".repeat(70 * 1024); - let count = 0; - const requests: string[] = []; - const binding = options.bindings.providers!.get(profile.id)!; - const session = await createSession({ - ...options, - bindings: { - ...options.bindings, - streamUpdate: () => Promise.reject(new Error("display unavailable")), - providers: new Map([ - [ - profile.id, - { - ...binding, - transport: { - ...binding.transport, - fetch: (async (_url, init) => { - requests.push(String(init?.body)); - return streamResponse(streamVector(profile, count++ === 0, signature)); - }) as typeof fetch, - }, - }, - ], - ]), - }, - }); - const result = await session.input("Go").settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - expect(requests[1]).toContain(signature); - expect( - session.snapshot.durable.continuations?.every((entry) => entry.payloadBlob && !entry.payload), - ).toBe(true); - expect(journalJSONL(session.snapshot.durable)).not.toContain(signature); - await session.close(); -}); diff --git a/packages/core-agent/session/test-support.ts b/packages/core-agent/session/test-support.ts deleted file mode 100644 index a4d05dff..00000000 --- a/packages/core-agent/session/test-support.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { z } from "zod"; - -import type { RuntimeOptions } from "../agent/agent-runtime.ts"; -import { completionTransport } from "../host/ports.ts"; -import { defineTool, type SessionOptions } from "./session-runtime.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -export { deferred, until } from "../agent/test-support.ts"; - -export function deterministicIds() { - let sequence = 0; - return () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; -} - -type TestOptions = Omit & { - persistence: SessionOptions["persistence"]; - id?: () => string; - systemInputs?: readonly string[]; -}; - -export function testOptions(overrides: Partial = {}): SessionOptions { - const value: TestOptions = { - agent: "a", - steps: 4, - baseUrl: "https://example.invalid", - apiKey: "SECRET_SENTINEL", - agents: new Map([ - ["a", { model: "test-model", systemPrompt: "Agent A", tools: ["echo"] }], - ["b", { model: "test-model", systemPrompt: "Agent B", tools: ["echo"] }], - ]), - tools: new Map([ - ["echo", defineTool({ input: z.object({ text: z.string() }), run: ({ text }) => text })], - ]), - complete: () => ({ kind: "answer", text: "Hello" }), - persistence: createMemoryPersistence(), - id: deterministicIds(), - ...overrides, - }; - return { - persistence: value.persistence, - configuration: { - agent: value.agent, - agents: value.agents, - steps: value.steps, - systemInputs: value.systemInputs, - }, - bindings: { - tools: value.tools, - id: value.id, - toolUpdate: value.toolUpdate, - streamUpdate: value.streamUpdate, - effects: value.effects, - complete: value.complete - ? (request, signal) => - value.complete!({ ...request, signal, baseUrl: value.baseUrl, apiKey: value.apiKey }) - : completionTransport(value), - }, - }; -} - -export function scriptedCompletion(outcomes: readonly unknown[], requests: unknown[] = []) { - let index = 0; - return ( - request: Pick< - Parameters>[0], - "model" | "messages" | "tools" - >, - ) => { - requests.push( - structuredClone({ model: request.model, messages: request.messages, tools: request.tools }), - ); - const result = outcomes[index++]; - if (result instanceof Error) throw result; - if (result === undefined) throw new Error("Completion script exhausted"); - return result; - }; -} - -export function lostAcknowledgement( - port: SessionOptions["persistence"], - predicate: (records: readonly string[]) => boolean = () => true, -): SessionOptions["persistence"] { - let lost = false; - return { - lifetime: port.lifetime, - putBlob: port.putBlob.bind(port), - getBlob: port.getBlob.bind(port), - load: port.load.bind(port), - getConfig: port.getConfig.bind(port), - putConfig: port.putConfig.bind(port), - async append(request, signal) { - const result = await port.append(request, signal); - if (!lost && result.kind === "committed" && predicate(request.records)) { - lost = true; - return { kind: "indeterminate", message: "Receipt lost" }; - } - return result; - }, - }; -} diff --git a/packages/core-agent/session/testing/memory-persistence.ts b/packages/core-agent/session/testing/memory-persistence.ts deleted file mode 100644 index 041a4f48..00000000 --- a/packages/core-agent/session/testing/memory-persistence.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - BlobIdSchema, - BlobInputMetaSchema, - BlobMetaSchema, - hashBlob, - MAX_BLOB_BYTES, - type BlobMeta, -} from "../../agent/content.ts"; -import { SessionIdSchema } from "../../agent/types.ts"; -import { freeze } from "../../fsm/fsm.ts"; -import type { Policy } from "../../policy/policy.ts"; -import { - AppendRequestSchema, - INITIAL_REVISION, - RevisionSchema, - type CommittedBatch, - type SessionPersistence, -} from "../persistence.ts"; - -/** Shared process-local data, intentionally outside all domain snapshots. No crash durability. */ -export function createMemoryBacking() { - return Object.assign(new Map(), { - blobs: new Map>(), - config: new Map(), - }); -} -export function createMemoryPersistence(backing = createMemoryBacking()): SessionPersistence { - return { - lifetime: "process-local", - async putBlob(rawSessionId, rawBytes, rawMeta, signal) { - signal.throwIfAborted(); - const sessionId = SessionIdSchema.parse(rawSessionId); - const metadata = BlobInputMetaSchema.parse(rawMeta); - if (!(rawBytes instanceof Uint8Array)) throw new Error("Blob must be a Uint8Array"); - if (rawBytes.byteLength > MAX_BLOB_BYTES) throw new Error("Blob exceeds 8 MiB"); - const bytes = Uint8Array.from(rawBytes); - const id = hashBlob(bytes); - const blobs = backing.blobs.get(sessionId) ?? new Map(); - const previous = blobs.get(id); - if (previous) { - if (previous.meta.media !== metadata.media) - throw new Error("Blob media cannot change for existing bytes"); - return previous.meta; - } - const meta = BlobMetaSchema.parse({ ...metadata, id, bytes: bytes.byteLength }); - blobs.set(id, { meta, bytes }); - backing.blobs.set(sessionId, blobs); - return meta; - }, - async getBlob(rawSessionId, rawId, signal) { - signal.throwIfAborted(); - const sessionId = SessionIdSchema.parse(rawSessionId); - const id = BlobIdSchema.parse(rawId); - const entry = backing.blobs.get(sessionId)?.get(id); - return entry ? { meta: entry.meta, bytes: entry.bytes.slice() } : { kind: "not_found" }; - }, - async load(sessionId, signal) { - if (signal.aborted) return { kind: "failed", message: "Load cancelled" }; - const batches = backing.get(sessionId); - return batches - ? freeze({ - kind: "loaded", - revision: batches.at(-1)!.revision, - batches: structuredClone(batches), - }) - : { kind: "not_found" }; - }, - async append(raw, signal) { - const parsed = AppendRequestSchema.safeParse(raw); - if (!parsed.success) return { kind: "rejected", message: parsed.error.message }; - const request = parsed.data; - const batches = backing.get(request.sessionId) ?? []; - const previous = batches.find((batch) => batch.appendId === request.appendId); - if (previous) { - const { revision, ...original } = previous; - return JSON.stringify(original) === JSON.stringify(request) - ? { - kind: "committed", - receipt: { sessionId: request.sessionId, appendId: request.appendId, revision }, - } - : { kind: "rejected", message: "Append ID reused with different content" }; - } - if (signal.aborted) return { kind: "rejected", message: "Cancelled before append" }; - const revision = batches.at(-1)?.revision ?? INITIAL_REVISION; - if (revision !== request.expectedRevision) return { kind: "conflict", revision }; - const next = RevisionSchema.parse(revision + request.records.length); - backing.set( - request.sessionId, - freeze([...batches, { ...structuredClone(request), revision: next }]), - ); - return { - kind: "committed", - receipt: { sessionId: request.sessionId, appendId: request.appendId, revision: next }, - }; - }, - async getConfig(rawSessionId, signal) { - signal.throwIfAborted(); - const sessionId = SessionIdSchema.parse(rawSessionId); - return backing.config.get(sessionId); - }, - async putConfig(rawSessionId, policy, signal) { - signal.throwIfAborted(); - const sessionId = SessionIdSchema.parse(rawSessionId); - backing.config.set(sessionId, structuredClone(policy)); - }, - }; -} diff --git a/packages/core-agent/session/testing/persistence-contract.ts b/packages/core-agent/session/testing/persistence-contract.ts deleted file mode 100644 index 24e408a7..00000000 --- a/packages/core-agent/session/testing/persistence-contract.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, test } from "@logtape/testing-bun/autoload"; - -import { SessionIdSchema } from "../../agent/types.ts"; -import { PolicySchema } from "../../policy/policy.ts"; -import { - AppendIdSchema, - BlobIdSchema, - INITIAL_REVISION, - MAX_BLOB_BYTES, - RevisionSchema, - type AppendRequest, - type SessionPersistence, -} from "../persistence.ts"; - -/** Each factory call isolates a test; reader() supplies an independent connection to the same data. */ -export function persistenceContract( - name: string, - factory: () => { writer: SessionPersistence; reader: () => SessionPersistence }, -) { - describe(name, () => { - const sessionId = SessionIdSchema.parse("00000000-0000-4000-8000-000000000001"); - const signal = () => new AbortController().signal; - const request = (extra: Partial = {}): AppendRequest => ({ - sessionId, - expectedRevision: INITIAL_REVISION, - appendId: AppendIdSchema.parse("one"), - records: ['{"one":1}', '{"two":2}'], - ...extra, - }); - test("blob put/get is content-addressed, session-scoped, idempotent and alias-safe", async () => { - const { writer, reader } = factory(); - const bytes = new TextEncoder().encode("abc"); - const put = writer.putBlob( - sessionId, - bytes, - { media: "text/markdown", name: "DESIGN.md" }, - signal(), - ); - bytes.fill(0); - const meta = await put; - expect(String(meta.id)).toBe( - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", - ); - const original = new TextEncoder().encode("abc"); - expect( - await writer.putBlob( - sessionId, - original, - { media: "text/markdown", name: "DESIGN.md" }, - signal(), - ), - ).toEqual(meta); - const loaded = await reader().getBlob(sessionId, meta.id, signal()); - if ("kind" in loaded) throw new Error("Missing blob"); - expect(loaded.meta).toEqual(meta); - expect(loaded.bytes).toEqual(original); - loaded.bytes.fill(0); - expect(await reader().getBlob(sessionId, meta.id, signal())).toEqual({ - meta, - bytes: original, - }); - expect( - await reader().getBlob( - SessionIdSchema.parse("00000000-0000-4000-8000-000000000002"), - meta.id, - signal(), - ), - ).toEqual({ kind: "not_found" }); - expect( - await reader().getBlob(sessionId, BlobIdSchema.parse("0".repeat(64)), signal()), - ).toEqual({ kind: "not_found" }); - expect(await reader().load(sessionId, signal())).toEqual({ kind: "not_found" }); - }); - test("blob size and cancellation are checked before writing", async () => { - const { writer, reader } = factory(); - const controller = new AbortController(); - controller.abort(); - await expect( - writer.putBlob(sessionId, new Uint8Array([1]), { media: "image/png" }, controller.signal), - ).rejects.toThrow(); - await expect( - reader().getBlob(sessionId, BlobIdSchema.parse("0".repeat(64)), controller.signal), - ).rejects.toThrow(); - await expect( - writer.putBlob( - sessionId, - new Uint8Array(MAX_BLOB_BYTES + 1), - { media: "image/png" }, - signal(), - ), - ).rejects.toThrow(); - expect( - await reader().getBlob( - sessionId, - BlobIdSchema.parse("4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"), - signal(), - ), - ).toEqual({ kind: "not_found" }); - }); - test("creates absent streams, preserves order, and exposes atomic batches to independent readers", async () => { - const { writer, reader } = factory(); - expect(await reader().load(sessionId, signal())).toEqual({ kind: "not_found" }); - const first = await writer.append(request(), signal()); - expect(first).toMatchObject({ - kind: "committed", - receipt: { sessionId, appendId: "one", revision: 2 }, - }); - expect( - await writer.append( - request({ - expectedRevision: RevisionSchema.parse(2), - appendId: AppendIdSchema.parse("two"), - records: ["third"], - }), - signal(), - ), - ).toMatchObject({ kind: "committed", receipt: { revision: 3 } }); - const loaded = await reader().load(sessionId, signal()); - expect(loaded.kind).toBe("loaded"); - if (loaded.kind === "loaded") - expect(loaded.batches.flatMap((batch) => batch.records)).toEqual([ - ...request().records, - "third", - ]); - }); - test("idempotent retries precede revision checks; ID reuse with different bytes is rejected", async () => { - const { writer, reader } = factory(); - const first = await writer.append(request(), signal()); - await writer.append( - request({ - appendId: AppendIdSchema.parse("later"), - expectedRevision: RevisionSchema.parse(2), - }), - signal(), - ); - expect(await reader().append(request(), signal())).toEqual(first); - expect((await writer.append(request({ records: ["changed"] }), signal())).kind).toBe( - "rejected", - ); - expect( - (await writer.append(request({ expectedRevision: RevisionSchema.parse(4) }), signal())) - .kind, - ).toBe("rejected"); - const loaded = await reader().load(sessionId, signal()); - expect(loaded.kind === "loaded" && Number(loaded.revision)).toBe(4); - }); - test("competing writers commit exactly one complete batch", async () => { - const { writer, reader } = factory(); - const results = await Promise.all([ - writer.append(request(), signal()), - reader().append(request({ appendId: AppendIdSchema.parse("competitor") }), signal()), - ]); - expect(results.map((result) => result.kind).sort()).toEqual(["committed", "conflict"]); - const loaded = await reader().load(sessionId, signal()); - expect(loaded.kind === "loaded" && loaded.batches.length).toBe(1); - expect(loaded.kind === "loaded" && Number(loaded.revision)).toBe(2); - }); - test("empty batch rejects without creating a stream; cancellation never implies rollback", async () => { - const { writer, reader } = factory(); - expect((await writer.append(request({ records: [] }), signal())).kind).toBe("rejected"); - expect((await reader().load(sessionId, signal())).kind).toBe("not_found"); - const controller = new AbortController(); - controller.abort(); - const result = await writer.append(request(), controller.signal); - const loaded = await reader().load(sessionId, signal()); - if (result.kind === "committed") expect(loaded.kind).toBe("loaded"); - if (result.kind === "rejected" || result.kind === "conflict") - expect(loaded.kind).toBe("not_found"); - expect(["committed", "rejected", "indeterminate"]).toContain(result.kind); - }); - test("read values and submitted arrays cannot mutate committed bytes", async () => { - const { writer, reader } = factory(); - const records = ["original"]; - await writer.append(request({ records }), signal()); - records[0] = "mutated"; - const first = await reader().load(sessionId, signal()); - const second = await reader().load(sessionId, signal()); - expect(first).toEqual(second); - expect(second.kind === "loaded" && second.batches[0]!.records).toEqual(["original"]); - }); - test("the configuration store keeps the latest selection per session, outside the journal", async () => { - const { writer, reader } = factory(); - const selected = PolicySchema.parse({ - id: "default@1", - version: 3, - steps: 4, - admission: "reject-during-tools", - bargeIn: true, - toolFailure: "fail-turn", - project: "history@1", - handoff: "handoff-slim@1", - tools: { a: ["echo"] }, - }); - const other = SessionIdSchema.parse("00000000-0000-4000-8000-000000000002"); - expect(await reader().getConfig(sessionId, signal())).toBeUndefined(); - await writer.putConfig(sessionId, selected, signal()); - expect(await reader().getConfig(sessionId, signal())).toEqual(selected); - expect(await reader().getConfig(other, signal())).toBeUndefined(); - expect(await reader().load(sessionId, signal())).toEqual({ kind: "not_found" }); - const latest = PolicySchema.parse({ ...selected, version: 4, steps: 8, tools: { a: [] } }); - await writer.putConfig(sessionId, latest, signal()); - expect(await reader().getConfig(sessionId, signal())).toEqual(latest); - const cancelled = new AbortController(); - cancelled.abort(); - await expect(writer.putConfig(sessionId, selected, cancelled.signal)).rejects.toThrow(); - expect(await reader().getConfig(sessionId, signal())).toEqual(latest); - }); - }); -} diff --git a/packages/core-agent/session/thinking-runtime.test.ts b/packages/core-agent/session/thinking-runtime.test.ts deleted file mode 100644 index 515e6a73..00000000 --- a/packages/core-agent/session/thinking-runtime.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { builtinResolvers } from "../policy/policy.ts"; -import { anthropicMessagesV2, googleGenerate } from "../providers/index.ts"; -import { AppendIdSchema, type CommittedBatch, type SessionPersistence } from "./persistence.ts"; -import { decodeRecord, replay, stage } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deterministicIds } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -function setup(responses?: readonly unknown[]) { - const bodies: { - messages?: { role: string; content: { type: string; signature?: string }[] }[]; - }[] = []; - let count = 0; - const opts: SessionOptions = { - persistence: createMemoryPersistence(), - configuration: { - agent: "a", - agents: new Map([["a", { model: "claude-sonnet-4-5", tools: ["echo"], successors: [] }]]), - steps: 4, - policy: { - provider: anthropicMessagesV2.id, - thinking: "budget", - thinkingBudgetTokens: 1024, - maxOutputTokens: 2048, - }, - }, - bindings: { - id: deterministicIds(), - tools: new Map([["echo", defineTool({ input: z.object({}), run: () => "ok" })]]), - providers: new Map( - [anthropicMessagesV2, googleGenerate].map((profile) => [ - profile.id, - { - profile, - transport: { - baseUrl: "https://example.invalid", - fetch: (async (_url, init) => { - bodies.push(JSON.parse(String(init?.body))); - if (profile.id === googleGenerate.id) - return Response.json({ - candidates: [ - { - finishReason: "STOP", - content: { role: "model", parts: [{ text: "google" }] }, - }, - ], - }); - const i = count++; - if (responses?.[i]) return Response.json(responses[i]); - return Response.json({ - role: "assistant", - stop_reason: i < 2 ? "tool_use" : "end_turn", - content: [ - { type: "thinking", thinking: "private", signature: `sig-${i}` }, - i < 2 - ? { type: "tool_use", id: `c${i}`, name: "echo", input: {} } - : { type: "text", text: "done" }, - ], - }); - }) as typeof fetch, - }, - }, - ]), - ), - }, - }; - return { opts, bodies }; -} - -const resolvers = { - ...builtinResolvers, - providerIds: new Set([anthropicMessagesV2.id, googleGenerate.id]), -}; - -test("thinking survives two tool rounds, restore, fork; projection/switch omit and compaction drops", async () => { - const { opts, bodies } = setup(); - const session = await createSession(opts); - const settled = await session.input("Go").settled; - expect(settled.kind === "terminal" && settled.record.outcome.kind).toBe("completed"); - expect(session.snapshot.durable.continuations).toHaveLength(3); - expect( - bodies[2]?.messages?.filter((m) => m.role === "assistant").map((m) => m.content[0]?.signature), - ).toEqual(["sig-0", "sig-1"]); - const id = session.snapshot.durable.conversation.sessionId; - const restored = await restoreSession(opts, id); - expect(bodies).toHaveLength(3); - expect(restored.snapshot.durable).toEqual(session.snapshot.durable); - const fork = await session.fork(); - expect(fork.snapshot.durable.continuations).toEqual(session.snapshot.durable.continuations); - await fork.input("next").settled; - expect( - bodies - .at(-1) - ?.messages?.filter((m) => m.role === "assistant") - .map((m) => m.content[0]?.signature), - ).toEqual(["sig-0", "sig-1", "sig-2"]); - const compact = await session.compact([]); - expect(compact.snapshot.durable.continuations ?? []).toEqual([]); - await session.updatePolicy({ project: "context-only@1" }); - await session.input("context only").settled; - expect(bodies.at(-1)?.messages?.filter((m) => m.role === "assistant")).toEqual([]); - const retained = session.snapshot.durable.continuations; - expect((await session.updatePolicy({ provider: googleGenerate.id })).kind).toBe("failed"); - expect( - ( - await session.updatePolicy({ - provider: googleGenerate.id, - thinking: "off", - thinkingBudgetTokens: null, - }) - ).kind, - ).toBe("accepted"); - await session.input("google").settled; - expect(session.snapshot.durable.continuations).toEqual(retained); - expect(JSON.stringify(bodies.at(-1))).not.toContain("sig-"); - await Promise.all([session.close(), restored.close(), fork.close(), compact.close()]); -}); - -test("staging rejects altered owners; load keeps stored envelopes", async () => { - const { opts } = setup(); - const session = await createSession(opts); - await session.input("Go").settled; - const loaded = await opts.persistence.load( - session.snapshot.durable.conversation.sessionId, - new AbortController().signal, - ); - if (loaded.kind !== "loaded") throw new Error("missing"); - const altered = (change: (entry: any) => void) => - loaded.batches.map((batch) => ({ - ...batch, - records: batch.records.map((raw) => { - const entry = JSON.parse(raw); - change(entry); - return JSON.stringify(entry); - }), - })); - for (const record of session.snapshot.durable.records.filter( - (r) => - r.body.kind === "created" || - (r.body.kind === "event" && - r.body.event.type === "child" && - r.body.event.event.type === "model_settled"), - )) - expect(() => decodeRecord(JSON.stringify({ ...record, version: 3 }))).toThrow(); - const staging = (batches: readonly CommittedBatch[], at: number) => { - const body = decodeRecord(batches[at]!.records[0]!).body; - if (body.kind !== "event") throw new Error("Expected an event record"); - return () => - stage( - replay(loaded.batches.slice(0, at)), - body, - AppendIdSchema.parse(batches[at]!.appendId), - resolvers, - ); - }; - const settled = loaded.batches.findIndex((batch) => { - const body = decodeRecord(batch.records[0]!).body; - return ( - body.kind === "event" && - body.event.type === "child" && - body.event.event.type === "model_settled" && - body.event.event.continuation !== undefined - ); - }); - const moved = altered((e) => { - const c = e.body.event?.event?.continuation; - if (c) c.owner.generation++; - }); - expect(staging(moved, settled)).toThrow("owner"); - expect(replay(moved).continuations).toEqual( - session.snapshot.durable.continuations?.map((entry) => ({ - ...entry, - owner: { ...entry.owner, generation: entry.owner.generation + 1 }, - })), - ); - expect(() => - replay( - altered((e) => { - const c = e.body.event?.event?.continuation; - if (c) c.payload = "x".repeat(65537); - }), - ), - ).toThrow("record_decode"); - await session.close(); -}); - -test("indeterminate envelope append reconciles stable bytes before releasing tools", async () => { - const { opts, bodies } = setup(); - const backing = opts.persistence; - let lost = false; - const persistence: SessionPersistence = { - lifetime: backing.lifetime, - putBlob: backing.putBlob.bind(backing), - getBlob: backing.getBlob.bind(backing), - load: backing.load.bind(backing), - getConfig: backing.getConfig.bind(backing), - putConfig: backing.putConfig.bind(backing), - async append(request, signal) { - const result = await backing.append(request, signal); - if (!lost && request.records.some((raw) => JSON.parse(raw).body.event?.event?.continuation)) { - lost = true; - return { kind: "indeterminate", message: "lost receipt" }; - } - return result; - }, - }; - const session = await createSession({ ...opts, persistence }); - const settled = await session.input("Go").settled; - expect(settled.kind === "terminal" && settled.record.outcome.kind).toBe("completed"); - expect(lost).toBe(true); - expect(bodies).toHaveLength(3); - expect(session.snapshot.durable.continuations).toHaveLength(3); - await session.close(); -}); - -test("handoff projection keeps only the envelope belonging to its retained assistant", async () => { - const reply = (signature: string, content: unknown) => ({ - role: "assistant", - stop_reason: "tool_use", - content: [{ type: "thinking", thinking: "private", signature }, content], - }); - const { opts, bodies } = setup([ - reply("tool-signature", { type: "tool_use", id: "c", name: "echo", input: {} }), - reply("handoff-signature", { - type: "tool_use", - id: "h", - name: "handoff_to", - input: { agent: "b" }, - }), - reply("answer-signature", { type: "text", text: "done" }), - ]); - const configured = { - ...opts, - configuration: { - ...opts.configuration, - agents: new Map([ - ["a", { model: "test", tools: ["echo"], successors: ["b"] }], - ["b", { model: "test", tools: ["echo"], successors: [] }], - ]), - }, - }; - const session = await createSession(configured); - const outcome = await session.input("Go").settled; - expect(outcome.kind === "terminal" && outcome.record.outcome.kind).toBe("completed"); - expect( - bodies[2]?.messages - ?.filter((message) => message.role === "assistant") - .map((message) => message.content[0]?.signature), - ).toEqual(["handoff-signature"]); - const restored = await restoreSession( - configured, - session.snapshot.durable.conversation.sessionId, - ); - expect(bodies).toHaveLength(3); - expect(restored.snapshot.durable.continuations).toHaveLength(3); - await Promise.all([session.close(), restored.close()]); -}); diff --git a/packages/core-agent/session/tool-updates.test.ts b/packages/core-agent/session/tool-updates.test.ts deleted file mode 100644 index 652b4923..00000000 --- a/packages/core-agent/session/tool-updates.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; -import { z } from "zod"; - -import { createAgentRuntime } from "../agent/agent-runtime.ts"; -import type { HostToolNotification } from "../host/host.ts"; -import { journalJSONL } from "./session-log.ts"; -import { - createSession, - defineTool, - restoreSession, - type SessionOptions, -} from "./session-runtime.ts"; -import { deferred, deterministicIds, testOptions, until } from "./test-support.ts"; -import { createMemoryPersistence } from "./testing/memory-persistence.ts"; - -const calls = { - kind: "tools", - text: "Review", - calls: [{ id: "same-call", name: "echo", args: { path: "/tmp/DESIGN.md" } }], -}; - -const answer = { kind: "answer", text: "done" }; - -const tool = () => - defineTool({ - input: z.object({ path: z.string() }), - kind: "read", - locations: ({ path }) => [{ path, line: 2 }], - run: () => "contents", - }); - -test("session tool notifications respect intent receipt but never certify result commit; restore is silent", async () => { - const base = createMemoryPersistence(); - const intent = deferred(); - const result = deferred(); - let awaitingIntent = false; - let awaitingResult = false; - let completions = 0; - const updates: HostToolNotification[] = []; - const opts: SessionOptions = { - persistence: { - ...base, - append: async (request, signal) => { - const bodies = request.records.map((raw) => JSON.parse(raw).body); - if ( - !awaitingIntent && - bodies.some( - (body) => - body.event?.event?.type === "model_settled" && - body.event.event.result?.value?.kind === "tools", - ) - ) { - awaitingIntent = true; - await intent.promise; - } - if (!awaitingResult && bodies.some((body) => body.kind === "tool")) { - awaitingResult = true; - await result.promise; - } - return base.append(request, signal); - }, - }, - configuration: { - agent: "a", - agents: new Map([["a", { model: "m", tools: ["echo"] }]]), - steps: 4, - }, - bindings: { - id: deterministicIds(), - tools: new Map([["echo", tool()]]), - complete: () => (++completions % 2 ? calls : answer), - toolUpdate: (event) => { - updates.push(event); - }, - }, - }; - const session = await createSession(opts); - const pending = session.input("Review").settled; - await until(() => awaitingIntent); - expect(updates).toEqual([]); - intent.resolve(); - await until(() => awaitingResult); - expect(updates.map((event) => event.status).filter(Boolean)).toEqual([ - "pending", - "in_progress", - "completed", - ]); - expect(completions).toBe(1); - expect(session.snapshot.durable.records.some((record) => record.body.kind === "tool")).toBe( - false, - ); - result.resolve(); - await pending; - expect(completions).toBe(2); - const serialized = journalJSONL(session.snapshot.durable); - expect(serialized).not.toContain('"sessionUpdate"'); - expect(serialized).not.toContain('"locations"'); - const count = updates.length; - const restored = await restoreSession(opts, session.snapshot.durable.conversation.sessionId); - expect(updates).toHaveLength(count); - const fork = await session.fork(); - // Binding callbacks are captured at construction, including for children. - (opts.bindings as { toolUpdate: unknown }).toolUpdate = () => { - throw new Error("replacement"); - }; - await fork.input("Again").settled; - expect(updates).toHaveLength(count * 2); - expect( - new Set( - updates - .filter((event) => event.sessionUpdate === "tool_call") - .map((event) => event.toolCallId), - ).size, - ).toBe(2); - expect(updates.at(-1)?.sessionId).toBe(fork.snapshot.durable.conversation.sessionId); - await Promise.all([session.close(), restored.close(), fork.close()]); -}); - -test("session and nonjournaled runtime expose the same optional tool sink", async () => { - for (const journaled of [true, false]) { - const updates: HostToolNotification[] = []; - let completions = 0; - const options = testOptions({ - tools: new Map([["echo", tool()]]), - complete: () => (++completions % 2 ? calls : answer), - toolUpdate: (event) => { - updates.push(event); - }, - }); - if (journaled) { - const session = await createSession(options); - await session.input("Review").settled; - expect(updates.at(-1)?.status).toBe("completed"); - await session.close(); - } else { - const runtime = createAgentRuntime({ - ...options.configuration, - ...options.bindings, - baseUrl: "https://example.invalid", - complete: () => (++completions % 2 ? calls : answer), - }); - await runtime.fire({ type: "user", text: "Review" }); - await until(() => runtime.snapshot.conversation.log.length === 1); - expect(updates.at(-1)?.status).toBe("completed"); - expect(updates[0]?.sessionId).toBe(runtime.snapshot.conversation.sessionId); - } - } -}); diff --git a/packages/core-agent/session/types.ts b/packages/core-agent/session/types.ts deleted file mode 100644 index 908111fc..00000000 --- a/packages/core-agent/session/types.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { z } from "zod"; - -import { BlobRefSchema } from "../agent/content.ts"; -import { PermissionDecisionsSchema } from "../agent/permissions.ts"; -import { parseSessionContext } from "../agent/prompt.ts"; -import { ToolRunResultSchema } from "../agent/tool-batch.ts"; -import { - ActorIdSchema, - AgentIdSchema, - CompletionSchema, - FailureSchema, - MessagesSchema, - OutcomeSchema, - SessionIdSchema, - ToolCallIdSchema, - ToolNameSchema, - TurnRecordSchema, -} from "../agent/types.ts"; -import { PolicySchema, PolicyVersionSchema } from "../policy/policy.ts"; -import { ContinuationSchema } from "../providers/types.ts"; -import { CompletionUsageSchema, type CompletionUsage } from "../providers/usage.ts"; -import { AppendIdSchema, RevisionSchema } from "./persistence.ts"; - -/** - * Standing session instructions (`systemInputs`), in order. Each step's prompt carries them as - * `system` messages after the agent's `systemPrompt`. "System" here is not a system notice. - */ -export const SystemInputsSchema = z.array(z.string()).readonly(); - -/** Version of the standing session instructions; each `system` record raises it by one. */ -export const SystemVersionSchema = z.number().int().nonnegative().brand<"SystemVersion">(); - -/** One agent in the registry ({@link Configuration}). */ -export const AgentDefinitionSchema = z - .strictObject({ - /** Model name the agent uses when the policy names no `model`; the policy's value wins. */ - model: z.string().min(1), - /** - * Agents this agent may hand off to. Omitted means every registered agent, itself included; - * `[]` offers no handoff. - */ - successors: z.array(AgentIdSchema).readonly().optional(), - /** The agent's own system prompt: the first message of each step's prompt. */ - systemPrompt: z.string().optional(), - /** - * Registered tools the agent may be offered. The policy's `tools` entry for the agent picks - * the subset each step advertises. - */ - tools: z.array(ToolNameSchema).default([]).readonly(), - }) - .readonly(); - -/** Schema of {@link Configuration}. */ -export const ConfigurationSchema = z - .strictObject({ - /** Registered agents by ID. Successors and tools must name registered agents and tools. */ - agents: z.array(z.tuple([AgentIdSchema, AgentDefinitionSchema])).readonly(), - /** Registered tools by name with their parameters' JSON Schema. Tool code is not journaled. */ - tools: z.array(z.tuple([ToolNameSchema, z.record(z.string(), z.json())])).readonly(), - }) - .refine((configuration) => { - const agents = new Set(configuration.agents.map(([id]) => id)); - const tools = new Set(configuration.tools.map(([name]) => name)); - return ( - agents.size === configuration.agents.length && - tools.size === configuration.tools.length && - configuration.agents.every( - ([, agent]) => - new Set(agent.tools).size === agent.tools.length && - (agent.successors ?? []).every((id) => agents.has(id)) && - agent.tools.every((name) => tools.has(name)), - ) - ); - }, "Configuration identities must be unique and tool references must exist") - .readonly(); - -/** - * The registry: the tool and agent definitions a session runs with. The creation record holds the - * initial registry and a `configuration` record adopts a live one on reopen. Not the glossary's - * configuration (the user-selectable settings), which is the policy ({@link PolicySchema}). - */ -export type Configuration = z.infer; - -/** Schema of {@link Seed}. */ -export const SeedSchema = z - .strictObject({ - /** Session this seed creates; every record of its journal carries it. */ - sessionId: SessionIdSchema, - /** - * How the session came to exist. `root` is a new session. `fork` and `compaction` are child - * sessions (not child operations) of `parent`, branched when the parent's next turn number - * was `sequence`. - */ - origin: z.discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("root") }), - z.strictObject({ - kind: z.enum(["fork", "compaction"]), - parent: SessionIdSchema, - sequence: z.number().int().positive(), - }), - ]), - /** - * Messages placed before the turn log in the history: the parent's context for a fork, the - * caller's replacement context for a compaction. - */ - context: MessagesSchema.transform(parseSessionContext), - /** Committed turn records inherited from the parent. Empty for root and compaction sessions. */ - log: z.array(TurnRecordSchema).readonly(), - /** Agent the session's next turn starts with. */ - agent: AgentIdSchema, - - /** - * Number of the next turn, `log.length + 1`; its turn ID is `/turn/`. A - * fork keeps its parent's sequence; root and compaction sessions start at 1. - */ - sequence: z.number().int().positive(), - /** Standing session instructions in force at creation. */ - systemInputs: SystemInputsSchema, - /** Version of `systemInputs` at creation. */ - systemVersion: SystemVersionSchema, - /** Registry the session starts with. */ - configuration: ConfigurationSchema, - /** Configuration (user-selectable settings) the session starts with. */ - policy: PolicySchema, - /** - * Provider continuation payloads (such as thinking signatures) a fork inherits for assistant - * messages in its context or log. Only a fork carries them; compaction drops them. - */ - continuations: z.array(ContinuationSchema).readonly().optional(), - }) - .readonly(); - -/** - * Initial state of a session, committed as the `created` record: the first record of every - * journal, and the only one of its kind. Staging a seed (`stageCreation`) checks the - * commit-time rules; load takes the committed seed as written. - */ -export type Seed = z.infer; - -const child = (kind: K) => - z.strictObject({ kind: z.literal(kind), id: ActorIdSchema }).readonly(); - -const result = (value: T) => - z - .discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("succeeded"), value }), - z.strictObject({ kind: z.literal("failed"), error: FailureSchema }), - z.strictObject({ kind: z.literal("cancelled") }), - ]) - .readonly(); - -/** - * Raw outcome of one tool call, as a `tool` record stores it: `succeeded` with the result (text and - * any blob parts), `failed` with a structured failure, or `cancelled`. The policy's `toolFailure` - * decides at use whether a failure reaches the model as an error message; the stored result stays - * raw. - */ -export const ToolRunOutcomeSchema = result(ToolRunResultSchema); - -/** - * How a tool batch settled, as the `batch_settled` event stores it: `kind` and, when it did not - * succeed, why. Individual results live in committed `tool` records; the fold assembles them from - * `partial` rather than storing them again here. - */ -const BatchOutcomeSchema = z.discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("succeeded") }), - z.strictObject({ kind: z.literal("failed"), error: FailureSchema }), - z.strictObject({ kind: z.literal("cancelled") }), -]); - -/** Schema of {@link WireEvent}. */ -export const WireEventSchema = z.discriminatedUnion("type", [ - z.strictObject({ - type: z.literal("user"), - text: z.string(), - attachments: z.array(BlobRefSchema).min(1).readonly().optional(), - }), - z.strictObject({ type: z.literal("abort") }), - z.strictObject({ - type: z.literal("request"), - request: z.discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("fork"), id: ActorIdSchema, sessionId: SessionIdSchema }), - z.strictObject({ - kind: z.literal("compact"), - id: ActorIdSchema, - sessionId: SessionIdSchema, - context: MessagesSchema.transform(parseSessionContext), - }), - ]), - }), - z.strictObject({ - type: z.literal("child"), - turnId: ActorIdSchema, - event: z.discriminatedUnion("type", [ - z.strictObject({ - type: z.literal("model_settled"), - /** Provider the step was made with, when the policy named one. */ - provider: z.string().min(1).optional(), - /** Application model name the step was made with, whether or not it produced output. */ - model: z.string().min(1), - continuation: ContinuationSchema.optional(), - child: child("completion"), - result: result(CompletionSchema.brand<"AdmittedCompletion">()), - }), - z.strictObject({ - type: z.literal("permission_settled"), - child: child("permission"), - result: result(PermissionDecisionsSchema), - }), - z.strictObject({ - type: z.literal("batch_settled"), - child: child("batch"), - outcome: BatchOutcomeSchema, - }), - z.strictObject({ - type: z.literal("failed"), - child: z.discriminatedUnion("kind", [ - child("completion"), - child("batch"), - child("tool"), - child("permission"), - ]), - error: FailureSchema, - }), - ]), - }), -]); - -/** - * The journaled form of a conversation event, stored in `event` records. It holds no connection - * settings or credentials. - * - * - `user`: a prompt. It starts a turn when the session is idle; during a turn it is a barge-in, - * where the policy and turn phase allow one. - * - `abort`: cancel the active turn. - * - `request`: branch into child session `sessionId` (a fork or compaction, not a child - * operation). The branch is taken at the next idle boundary. - * - `child`: a child operation of turn `turnId` (not a child session) settled. `model_settled`: a - * settled step — prompt projection and the LLM call run as one operation — whose model output is - * now committed, along with the provider and model that produced it and any continuation. - * `permission_settled`: permission decisions for the tool batch. `batch_settled`: the tool - * batch's outcome (individual results live in `tool` records). `failed`: the operation failed - * without its normal result, for example because it could not be dispatched. - */ -export type WireEvent = z.infer; - -/** Schema of {@link JournalBody}. */ -export const BodySchema = z.discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("created"), seed: SeedSchema }), - z.strictObject({ kind: z.literal("policy"), policy: PolicySchema }), - z.strictObject({ - kind: z.literal("configuration"), - /** Live registry adopted from this record on. */ - configuration: ConfigurationSchema, - /** Policy reconciled to the live registry and bindings (version + 1); absent when unchanged. */ - policy: PolicySchema.optional(), - /** Agent the idle conversation switches to, when its current agent is no longer registered. */ - agent: AgentIdSchema.optional(), - }), - z.strictObject({ - kind: z.literal("queued"), - attachments: z.array(BlobRefSchema).min(1).readonly().optional(), - /** Queued-input ID; the `dequeued` or `input_cancelled` record names it. */ - inputId: ActorIdSchema, - text: z.string(), - /** Policy version in force when the input was queued. */ - policyVersion: PolicyVersionSchema, - }), - z.strictObject({ - kind: z.literal("dequeued"), - /** Queued-input ID of the queued input that starts this turn. */ - inputId: ActorIdSchema, - /** Policy version the turn starts under. */ - policyVersion: PolicyVersionSchema, - }), - z.strictObject({ - kind: z.literal("input_cancelled"), - inputId: ActorIdSchema, - reason: z.string(), - }), - z.strictObject({ - kind: z.literal("event"), - event: WireEventSchema, - /** Version of `systemInputs` the event was staged against. */ - systemVersion: SystemVersionSchema, - }), - z.strictObject({ - kind: z.literal("system"), - /** The complete replacement list of standing session instructions. */ - inputs: SystemInputsSchema, - /** New `systemInputs` version: the previous one plus one. */ - version: SystemVersionSchema, - }), - z.strictObject({ - kind: z.literal("tool"), - turnId: ActorIdSchema, - /** The tool batch the call belongs to (a tool batch, not a journal append). */ - batchId: ActorIdSchema, - callId: ToolCallIdSchema, - /** Raw outcome; see {@link ToolRunOutcomeSchema}. */ - result: ToolRunOutcomeSchema, - }), - z.strictObject({ - kind: z.literal("effect"), - turnId: ActorIdSchema, - /** The completion operation this effect is attributed to (not a child session). */ - operationId: ActorIdSchema, - effect: z.literal("usage"), - usage: CompletionUsageSchema, - }), - z.strictObject({ - kind: z.literal("terminal"), - turnId: ActorIdSchema, - agent: AgentIdSchema, - outcome: OutcomeSchema, - }), - z.strictObject({ kind: z.literal("recovery"), turnId: ActorIdSchema, reason: z.string().min(1) }), -]); - -/** - * The content of one journal record, discriminated by `kind`: - * - * - `created`: the session's {@link Seed}; the first record, and only there. - * - `policy`: a "configuration applied" fact holding the resulting settings the session runs - * under from the next turn. Staged only at an idle boundary; a running turn keeps the settings - * it started with. - * - `configuration`: registry adoption on reopen, committed before the first new work. - * - `queued`: a queued input, held until the current turn ends (policy `queue-user`, or - * `abort-tools-on-user` during tools or permission waiting). - * - `dequeued`: the first queued input starts a turn at an idle boundary. - * - `input_cancelled`: a queued input dropped without starting a turn; recovery cancels them all. - * - `event`: a conversation event ({@link WireEvent}). - * - `system`: a replacement of the standing session instructions (not a system notice). - * - `tool`: one tool call's raw result, committed before its tool batch settles. - * - `effect`: a chosen effect the environment records alongside a step, typed apart from facts. - * The conversation fold ignores these; only `usage` is defined today. - * - `terminal`: the outcome and final agent of a turn, committed in the same append as the record - * that ended it. Staging derives it; callers never submit it. The fold derives the turn's - * messages from its own facts, not from this record. - * - `recovery`: closes a turn that process exit interrupted, with an `interrupted` failure, when - * the session is reopened. External effects are not repeated. - * - * Staging checks the commit-time rules for each kind; load takes every record as written and - * checks only journal integrity (`JournalIntegrityRule` in session-log.ts). - */ -export type JournalBody = z.infer; - -/** Schema of {@link JournalRecord}. */ -export const JournalRecordSchema = z - .strictObject({ - /** Record format. This build reads and writes only version 2. */ - version: z.literal(2), - sessionId: SessionIdSchema, - /** Position in the session's journal: 1 for the creation record, then one more per record. */ - revision: RevisionSchema, - /** `/`. */ - entryId: z.string().min(1), - /** Stable ID of the journal append (not a tool batch) that committed the record. */ - appendId: AppendIdSchema, - body: BodySchema, - }) - .readonly(); - -/** - * One committed journal record: an envelope naming its session, revision and append around a - * {@link JournalBody}. - */ -export type JournalRecord = z.infer; - -/** - * What `stage` in session-log.ts accepts: any journal body except `terminal` and `effect`, which - * staging derives. An `event` may carry the completion's `usage`, staged as a sibling `effect` - * record. - */ -export type SessionInput = - | Exclude - | Readonly<{ - kind: "event"; - event: WireEvent; - systemVersion: z.infer; - /** Usage effect to record alongside the event, when the child reported one. */ - usage?: CompletionUsage; - }>; diff --git a/packages/core-agent/session/views.test.ts b/packages/core-agent/session/views.test.ts deleted file mode 100644 index 2f2b1fb4..00000000 --- a/packages/core-agent/session/views.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { expect, test } from "@logtape/testing-bun/autoload"; - -import { BlobRefSchema, hashBlob } from "../agent/content.ts"; -import { MessageSchema, type TurnRecord } from "../agent/types.ts"; -import { createSession } from "./session-runtime.ts"; -import { scriptedCompletion, testOptions } from "./test-support.ts"; -import { projectConversation, projectMessage, projectTurn } from "./views.ts"; - -const bytes = new TextEncoder().encode("hello"); -const ref = BlobRefSchema.parse({ id: hashBlob(bytes), media: "image/png", bytes: bytes.length }); - -test("projectMessage renders a user message's blob parts as blob:// refs", () => { - const message = MessageSchema.parse({ - role: "user", - text: "See attached", - parts: [ - { type: "text", text: "See attached" }, - { type: "blob", ref }, - ], - }); - expect(projectMessage(message)).toEqual({ - role: "user", - text: "See attached", - blobs: [ - { - id: ref.id, - media: "image/png", - bytes: ref.bytes, - uri: `blob://${ref.id}.png`, - }, - ], - }); -}); - -test("projectMessage keeps an attachment's name and carries an assistant step's owner and calls", () => { - const named = BlobRefSchema.parse({ ...ref, name: "diagram.png" }); - const message = MessageSchema.parse({ - role: "assistant", - text: "Working on it", - calls: [{ id: "c1", name: "search", args: { query: "x" } }], - owner: { turnId: "session-1/turn/1", generation: 2 }, - parts: [ - { type: "text", text: "Working on it" }, - { type: "blob", ref: named }, - ], - }); - expect(projectMessage(message)).toEqual({ - role: "assistant", - text: "Working on it", - blobs: [ - { - id: named.id, - media: "image/png", - bytes: named.bytes, - name: "diagram.png", - uri: `blob://${named.id}.png`, - }, - ], - calls: [{ id: "c1", name: "search", args: { query: "x" } }], - owner: { turnId: "session-1/turn/1", generation: 2 }, - }); -}); - -test("projectMessage renders a tool result by its answered call id, with no blobs", () => { - const message = MessageSchema.parse({ role: "tool", text: "42", callId: "c1" }); - expect(projectMessage(message)).toEqual({ role: "tool", text: "42", callId: "c1", blobs: [] }); -}); - -test("projectTurn preserves the turn's agent, outcome and message order", () => { - const turn: TurnRecord = { - agent: "a" as TurnRecord["agent"], - outcome: { kind: "completed" }, - messages: [ - MessageSchema.parse({ role: "user", text: "hi" }), - MessageSchema.parse({ role: "assistant", text: "hello" }), - ], - }; - expect(projectTurn(turn)).toEqual({ - agent: "a", - outcome: { kind: "completed" }, - messages: [ - { role: "user", text: "hi", blobs: [] }, - { role: "assistant", text: "hello", blobs: [] }, - ], - }); -}); - -test("projectConversation projects a folded session's context, log and live turn identically to its facts", async () => { - const options = testOptions({ - complete: scriptedCompletion([ - { - kind: "tools", - text: "checking", - calls: [{ id: "c1", name: "echo", args: { text: "hi" } }], - }, - { kind: "answer", text: "done" }, - ]), - }); - const session = await createSession(options); - const sessionId = session.snapshot.durable.conversation.sessionId; - const result = await session.input({ text: "Look" }).settled; - expect(result.kind === "terminal" && result.record.outcome.kind).toBe("completed"); - - const view = projectConversation(session.snapshot.durable); - expect(view.sessionId).toBe(sessionId); - expect(view.context).toEqual([]); - expect(view.live).toEqual([]); - expect(view.log).toHaveLength(1); - const [turn] = view.log; - expect(turn!.agent).toBe("a"); - expect(turn!.outcome).toEqual({ kind: "completed" }); - expect(turn!.messages).toEqual([ - { role: "user", text: "Look", blobs: [] }, - { - role: "assistant", - text: "checking", - blobs: [], - calls: [{ id: "c1", name: "echo", args: { text: "hi" } }], - }, - { role: "tool", text: "hi", callId: "c1", blobs: [] }, - { role: "assistant", text: "done", blobs: [] }, - ]); - - const compacted = await session.compact([ - MessageSchema.parse({ role: "user", text: "carried over" }), - ]); - expect(projectConversation(compacted.snapshot.durable).context).toEqual([ - { role: "user", text: "carried over", blobs: [] }, - ]); - expect(projectConversation(compacted.snapshot.durable).log).toEqual([]); -}); diff --git a/packages/core-agent/session/views.ts b/packages/core-agent/session/views.ts deleted file mode 100644 index 43f7e362..00000000 --- a/packages/core-agent/session/views.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { blobUri, type BlobRef, type MediaKind } from "../agent/content.ts"; -import type { AgentMessage, Json, Outcome, TurnRecord } from "../agent/types.ts"; -import type { JournalState } from "./journal/state.ts"; - -/** - * One attachment referenced from a message, as a consumer would render it: the same fields as - * {@link BlobRef} plus its resolvable `blob://` URI. Never carries the bytes. - */ -export type ViewBlob = Readonly<{ - id: string; - media: MediaKind; - bytes: number; - name?: string; - uri: string; -}>; - -/** One tool call a step proposed, as a consumer would render it. */ -export type ViewToolCall = Readonly<{ id: string; name: string; args: Json }>; - -/** The step that produced an assistant message. See {@link AgentMessage}'s `owner`. */ -export type ViewOwner = Readonly<{ turnId: string; generation: number }>; - -/** - * One projected message. This is the one shared view of a fact every consumer (ACP replay and - * live updates, the web console, `/export`) renders from, instead of each deriving its own shape - * from the folded conversation state. - */ -export type MessageView = - | Readonly<{ role: "system" | "user"; text: string; blobs: readonly ViewBlob[] }> - | Readonly<{ - role: "assistant"; - text: string; - blobs: readonly ViewBlob[]; - calls?: readonly ViewToolCall[]; - owner?: ViewOwner; - }> - | Readonly<{ role: "tool"; text: string; callId: string; blobs: readonly ViewBlob[] }>; - -/** A finished turn, projected. */ -export type TurnView = Readonly<{ - agent: string; - outcome: Outcome; - messages: readonly MessageView[]; -}>; - -/** A session's whole conversation, projected: inherited context, settled turns, and the live turn. */ -export type ConversationView = Readonly<{ - sessionId: string; - context: readonly MessageView[]; - log: readonly TurnView[]; - live: readonly MessageView[]; -}>; - -function projectBlob(ref: BlobRef): ViewBlob { - return { - id: ref.id, - media: ref.media, - bytes: ref.bytes, - ...(ref.name ? { name: ref.name } : {}), - uri: blobUri(ref), - }; -} - -/** - * Projects one fact message into its consumer view: text, blob refs (with their `blob://` URI), - * including those of a tool result, tool calls, and the tool call a result answers. A pure - * function of the message; never stored, never compared with a stored copy. - */ -export function projectMessage(message: AgentMessage): MessageView { - const blobs = (message.parts ?? []).flatMap((part) => - part.type === "blob" ? [projectBlob(part.ref)] : [], - ); - if (message.role === "tool") - return { role: "tool", text: message.text, callId: message.callId, blobs }; - if (message.role === "assistant") - return { - role: "assistant", - text: message.text, - blobs, - ...(message.calls - ? { - calls: message.calls.map((call) => ({ id: call.id, name: call.name, args: call.args })), - } - : {}), - ...(message.owner - ? { owner: { turnId: message.owner.turnId, generation: message.owner.generation } } - : {}), - }; - return { role: message.role, text: message.text, blobs }; -} - -/** Projects one finished turn: its messages, in the same order they were committed. */ -export function projectTurn(turn: TurnRecord): TurnView { - return { agent: turn.agent, outcome: turn.outcome, messages: turn.messages.map(projectMessage) }; -} - -/** - * Projects a session's whole conversation from folded state: inherited context, every settled - * turn, and the messages the live turn has added so far (empty while idle). Consumers project - * this further into their own wire shape (ACP `session/update`, the web console, `/export`); none - * of them derive conversation history separately. - */ -export function projectConversation(state: JournalState): ConversationView { - const c = state.conversation; - return { - sessionId: c.sessionId, - context: c.context.map(projectMessage), - log: c.log.map(projectTurn), - live: c.turn.status === "idle" ? [] : c.turn.turn.messages.map(projectMessage), - }; -} diff --git a/scripts/checks/check-comment-length.ts b/scripts/checks/check-comment-length.ts index 75436268..36d5baf2 100755 --- a/scripts/checks/check-comment-length.ts +++ b/scripts/checks/check-comment-length.ts @@ -19,7 +19,7 @@ const ROOTS = ["packages", "tests"]; // The agent packages arrived with longer comment blocks: 47 of them run past the bound. Their own // review is against this rule when they are next edited, and they are not rewritten in one pass. // retire-when: no block under these directories runs longer than eight lines. -const EXEMPT = ["packages/core-agent/", "packages/app-vscode/"]; +const EXEMPT = ["packages/app-vscode/"]; function tsFiles(dir: string): string[] { const found: string[] = []; From e7af00f9f309cbd5384eb4622d38c928e7366ae2 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 13:54:36 +0100 Subject: [PATCH 2/4] A file opened from the graph or a link opens the folders above it in File Explorer So its row and the box that marks it show. A folder closed afterwards stays closed until another file below it opens. Co-Authored-By: Claude Opus 5.5 --- packages/app-web/docs/architecture.md | 2 +- packages/app-web/e2e/app.e2e.ts | 20 ++++++++++++++++++++ packages/app-web/src/ui/trace/explorers.tsx | 15 ++++++++++++++- packages/app-web/tests/file-tree.test.ts | 19 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 packages/app-web/tests/file-tree.test.ts diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index bc7cdf2a..d725986a 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -154,7 +154,7 @@ The browser also keeps whether each section is folded. The sections list: | Section | Lists | Opening an item shows | | --- | --- | --- | -| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. | The file, shown as its media type. | +| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. A file opened from anywhere else, such as the graph or a link, opens the folders above it. | The file, shown as its media type. | | Source Explorer | Every branch's commits, newest first, with each branch's line drawn beside them as `git log --graph` draws it, and branch names on the commits they point to. A branch picker opens on HEAD's branch and scrolls to the picked branch's tip. The box that marks the open commit slides to the next commit opened. | The commit's properties, message, parents, changed files and diff. A changed file opens as it was at that commit. | | Record Explorer | The workspace's collections of records and acts, and the items of the one picked. The `file` and `commit` collections are left out. | The record. | diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index c4acd795..88713511 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -397,6 +397,26 @@ test.describe("the sidebar", () => { await expect.poll(async () => (await top(box)) - (await top(file("big.txt")))).toBe(0); }); + test("a file opened from a link opens the folders above it in File Explorer, and its row is marked", async ({ + page, + }) => { + await page.goto("/app/workspace/alpha/Q_1"); + await explorer(page, "File Explorer").click(); + const tree = page.locator(".file-tree"); + await expect(tree.getByRole("button", { name: "README.md", exact: true })).toBeVisible(); + await expect(tree.getByRole("button", { name: "plan.md", exact: true })).toHaveCount(0); + await page.goto("/app/workspace/alpha/file/notes/plan.md"); + await expect(header(page)).toHaveText("plan.md"); + const row = tree.getByRole("button", { name: "plan.md", exact: true }); + await expect(row).toBeVisible(); + await expect(row).toHaveClass(/active/); + const top = async (locator: import("@playwright/test").Locator) => + Math.round((await locator.boundingBox())?.y ?? -1); + await expect + .poll(async () => (await top(tree.locator(".active-box.outline"))) - (await top(row))) + .toBe(0); + }); + test("in Source Explorer, a box marks the open commit and slides to the next one opened", async ({ page, }) => { diff --git a/packages/app-web/src/ui/trace/explorers.tsx b/packages/app-web/src/ui/trace/explorers.tsx index 21a71abc..e89a5451 100644 --- a/packages/app-web/src/ui/trace/explorers.tsx +++ b/packages/app-web/src/ui/trace/explorers.tsx @@ -55,10 +55,18 @@ function Tail({ ); } +/** The keys of the directories above `key` in the tree at `rootKey`, top first; none outside it. */ +export function foldersAbove(rootKey: string, key: string): string[] { + if (!key.startsWith(`${rootKey}/`)) return []; + const segments = key.slice(rootKey.length + 1).split("/"); + return segments.slice(0, -1).map((_, i) => `${rootKey}/${segments.slice(0, i + 1).join("/")}`); +} + /** * The workspace's files as a tree, from the directory `rootKey`. A directory's entries are * fetched when it is first opened. Clicking a file opens it, and the box around the open file - * slides to it. + * slides to it. When a file in the tree opens from anywhere else, such as the graph or a link, the + * directories above it open, so its row and the box show; closing one afterwards keeps it closed. */ export function FileTree({ rootKey, @@ -71,6 +79,11 @@ export function FileTree({ }) { const [opened, setOpened] = useState>(new Set()); const list = useRef(null); + useEffect(() => { + const above = foldersAbove(rootKey, openKey); + if (above.length === 0) return; + setOpened((was) => (above.every((key) => was.has(key)) ? was : new Set([...was, ...above]))); + }, [rootKey, openKey]); const toggle = (key: string) => setOpened((was) => { const now = new Set(was); diff --git a/packages/app-web/tests/file-tree.test.ts b/packages/app-web/tests/file-tree.test.ts new file mode 100644 index 00000000..245295b6 --- /dev/null +++ b/packages/app-web/tests/file-tree.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { foldersAbove } from "../src/ui/trace/explorers"; + +describe("the folders File Explorer opens above an open file", () => { + test("are each directory between the tree's top and the file, top first", () => { + expect(foldersAbove("/workspace/a/file", "/workspace/a/file/notes/2026/plan.md")).toEqual([ + "/workspace/a/file/notes", + "/workspace/a/file/notes/2026", + ]); + }); + + test("are none for a file at the top, or for anything outside the tree", () => { + expect(foldersAbove("/workspace/a/file", "/workspace/a/file/README.md")).toEqual([]); + expect(foldersAbove("/workspace/a/file", "/workspace/a/commit/abc/file/notes/x.md")).toEqual( + [], + ); + expect(foldersAbove("/workspace/a/file", "/workspace/a/Q_1")).toEqual([]); + }); +}); From 4007d85e8f10a748fe237e24e5ba36f4663c74da Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 14:02:58 +0100 Subject: [PATCH 3/4] Items, File Explorer and Source Explorer each have a filter behind a search button The button sits at the right of the list's header; the box shows only when asked for, and hiding it (the button or Escape) empties it. - File Explorer's filter searches every committed path (/paths), so a file in a folder not yet opened is found; /paths now links each path's file by a templated link. - Source Explorer's filter narrows the commits loaded so far and says how many it searched. Co-Authored-By: Claude Opus 5.5 --- packages/app-web/docs/architecture.md | 8 +- packages/app-web/e2e/app.e2e.ts | 43 ++++++- packages/app-web/src/server/git-handler.ts | 6 +- packages/app-web/src/ui/chrome/chrome.css | 19 +++ .../app-web/src/ui/trace/TraceConsole.tsx | 110 ++++++++++++----- .../app-web/src/ui/trace/chat-sidebar.tsx | 41 +------ packages/app-web/src/ui/trace/explorers.tsx | 114 +++++++++++++++++- packages/app-web/src/ui/trace/file-paths.ts | 77 ++++++++++++ packages/app-web/src/ui/trace/filter.tsx | 79 ++++++++++++ packages/app-web/src/ui/trace/trace.css | 24 ++++ packages/app-web/tests/git-api.test.ts | 4 + 11 files changed, 449 insertions(+), 76 deletions(-) create mode 100644 packages/app-web/src/ui/trace/file-paths.ts create mode 100644 packages/app-web/src/ui/trace/filter.tsx diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index d725986a..db6d8699 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -131,7 +131,7 @@ Text that is not UTF-8, or a binary file of a text type, gets the note. | Column | Shows | |--------|-------| -| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest and a filter box that narrows the listed items. | +| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest. | | Centre | The graph, a card that folds: the resources opened in the workspace since its last reset and what they relate to, on a 3D canvas (`GraphView`). Clicking a node opens its resource. Below it, the open resource. Tabs: Overview (properties, then a card for each related resource, outbound then inbound), Debug, drawn as a wrench (the response; the events of the record, or of the acts that ran against the commit, each a line with chips for the records it names; and the commits those acts ran against). `?tab=` names the tab. The tabs and Reload sit at the right of the line with the resource's name. | | Right | In development, in a workspace: the agent's chat. At full width it slides open and shut beside a rail that stays; the rail's button expands or minimises it. In a window 860 px wide or narrower it is a drawer over the page from the right, opened by a button in the top bar. Its sessions open in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/`). A workspace's session connects the first time the chat is shown in that workspace, and stays connected while the chat is hidden; a workspace the chat was never shown in has no session. Each workspace keeps its session for the life of the page: leaving it and coming back reopens that session. `?session=` names the session of the workspace in scope, so a reload reopens it. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place, and `/sessions` lists the sessions the agent keeps for the workspace's folder (ACP `session/list` by `cwd`), newest first, in a palette; picking one reopens it. `@` offers the workspace's files at HEAD (`/paths`); each file a message mentions is sent with it as a `resource_link` to the file in the workspace's folder (`file:///`), which the agent reads itself. The border at the chat's left edge moves by drag, by Left and Right (16 px), and by Home and End (to 320 px, and to 60% of the window); a double-click returns it to 400 px, and the browser keeps the width. | @@ -154,10 +154,12 @@ The browser also keeps whether each section is folded. The sections list: | Section | Lists | Opening an item shows | | --- | --- | --- | -| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. A file opened from anywhere else, such as the graph or a link, opens the folders above it. | The file, shown as its media type. | -| Source Explorer | Every branch's commits, newest first, with each branch's line drawn beside them as `git log --graph` draws it, and branch names on the commits they point to. A branch picker opens on HEAD's branch and scrolls to the picked branch's tip. The box that marks the open commit slides to the next commit opened. | The commit's properties, message, parents, changed files and diff. A changed file opens as it was at that commit. | +| File Explorer | The workspace's files at HEAD, as a tree. A directory's entries are fetched when it is first opened. Each file's icon and colour come from its listed `media_type` (`file-icons.tsx`); a file listed without one has the plain file icon. The box that marks the open file slides to the next file opened. A file opened from anywhere else, such as the graph or a link, opens the folders above it. Its filter lists the files whose paths hold every word typed, from `/paths`, so files in folders not yet opened are found. | The file, shown as its media type. | +| Source Explorer | Every branch's commits, newest first, with each branch's line drawn beside them as `git log --graph` draws it, and branch names on the commits they point to. A branch picker opens on HEAD's branch and scrolls to the picked branch's tip. The box that marks the open commit slides to the next commit opened. Its filter narrows the commits loaded so far, by id, message, author or branch, and says how many it searched. | The commit's properties, message, parents, changed files and diff. A changed file opens as it was at that commit. | | Record Explorer | The workspace's collections of records and acts, and the items of the one picked. The `file` and `commit` collections are left out. | The record. | +Items, File Explorer and Source Explorer each have a filter, hidden until the search button at the right of the list's header shows it. Hiding a filter, by its button or by Escape in its box, empties it, so a hidden filter never narrows a list. + Open sections divide the sidebar's height in proportion to their shares. Record Explorer, which holds two panes, starts with twice the share of the others, and its Collections pane scrolls when it is short. Dragging the border between two open sections moves height from one section to the diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index 88713511..72965413 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -137,12 +137,21 @@ test.describe("getting around", () => { expect(await bar.evaluate((e) => getComputedStyle(e).height)).toBe("52px"); }); - test("the Items filter narrows the listed items", async ({ page }) => { + test("the Items filter is hidden behind a search button; it narrows the items, and Escape hides and empties it", async ({ + page, + }) => { await page.goto(`/app/workspace/alpha/Q_1?${list("/workspace/alpha/question")}`); await expect(item(page, "Q_2")).toBeVisible(); - await page.getByRole("textbox", { name: "Filter the items" }).fill("Q_2"); + const box = page.getByRole("textbox", { name: "Filter the items" }); + await expect(box).toHaveCount(0); + await page.getByRole("button", { name: "Filter the items" }).click(); + await expect(box).toBeFocused(); + await box.fill("Q_2"); await expect(item(page, "Q_2")).toBeVisible(); await expect(item(page, "Q_1")).toHaveCount(0); + await box.press("Escape"); + await expect(box).toHaveCount(0); + await expect(item(page, "Q_1")).toBeVisible(); }); test("in a short window, Collections takes at most 40% of Record Explorer and Items keeps the rest", async ({ @@ -417,6 +426,36 @@ test.describe("the sidebar", () => { .toBe(0); }); + test("File Explorer's filter finds a file in a folder not yet opened, and opens it", async ({ + page, + }) => { + await page.goto("/app/workspace/alpha/Q_1"); + await explorer(page, "File Explorer").click(); + const tree = page.locator(".file-tree"); + await page.getByRole("button", { name: "Filter the files" }).click(); + await page.getByRole("textbox", { name: "Filter the files" }).fill("plan"); + await expect(tree.locator(".tree-note")).toContainText(/^1 of \d+ files$/); + await tree.getByRole("button", { name: "notes/plan.md" }).click(); + await expect(header(page)).toHaveText("plan.md"); + await expect(tree.getByRole("button", { name: "notes/plan.md" })).toHaveClass(/active/); + }); + + test("Source Explorer's filter narrows the loaded commits and says how many it searched", async ({ + page, + }) => { + await page.goto("/app/workspace/alpha/Q_1"); + await explorer(page, "Source Explorer").click(); + const history = page.locator(".history"); + await expect(history.locator(".commit-row")).not.toHaveCount(0); + const loaded = await history.locator(".commit-row").count(); + await page.getByRole("button", { name: "Filter the commits" }).click(); + await page.getByRole("textbox", { name: "Filter the commits" }).fill("side work"); + await expect(history.locator(".commit-row .subject")).toHaveText(["Side work"]); + await expect(history.locator(".tree-note")).toContainText( + `1 of the ${loaded} commits loaded match`, + ); + }); + test("in Source Explorer, a box marks the open commit and slides to the next one opened", async ({ page, }) => { diff --git a/packages/app-web/src/server/git-handler.ts b/packages/app-web/src/server/git-handler.ts index 82f682ca..5838e24f 100644 --- a/packages/app-web/src/server/git-handler.ts +++ b/packages/app-web/src/server/git-handler.ts @@ -862,7 +862,8 @@ function rawFile( /** * Every file's path in the tree at HEAD, in BY_NAME order of the whole path: what can be mentioned - * in a message to the agent. Directories and submodules are not listed. At most MAX_PATHS are + * in a message to the agent, and what File Explorer's filter searches. The templated `file` link + * names each path's file. Directories and submodules are not listed. At most MAX_PATHS are * listed; `total` counts them all, and `truncated` says when some are left out. */ function filePaths(req: Request, repo: Repository, base: string, slug: string): Response { @@ -888,6 +889,9 @@ function filePaths(req: Request, repo: Repository, base: string, slug: string): _links: { self: { href: `${base}/paths` }, index: { href: `${base}/${FILE_SLUG}` }, + // Each listed path's file, by RFC 6570 reserved expansion: the path's slashes are kept and + // each segment is percent-encoded. + file: { href: `${base}/${FILE_SLUG}/{+path}`, templated: true }, }, total: paths.length, count: listed.length, diff --git a/packages/app-web/src/ui/chrome/chrome.css b/packages/app-web/src/ui/chrome/chrome.css index 62e7c868..1a187196 100644 --- a/packages/app-web/src/ui/chrome/chrome.css +++ b/packages/app-web/src/ui/chrome/chrome.css @@ -136,6 +136,25 @@ .lk-frame .items-filter::placeholder { color: var(--text-faint); } +/* The button that shows or hides a list's filter: a search icon with no border, like the rail's. */ +.lk-frame .filter-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 5px; + background: none; + color: var(--text-faint); + font-size: 14px; +} +.lk-frame .filter-btn:hover, +.lk-frame .filter-btn.on { + background: var(--panel-2); + color: var(--text); +} /* The rail: a narrow column of icons at the left edge that stays when the sidebar is hidden. Its first button shows or hides the sidebar; the others fold or open one section each. */ diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx index e6a3bef5..81387754 100644 --- a/packages/app-web/src/ui/trace/TraceConsole.tsx +++ b/packages/app-web/src/ui/trace/TraceConsole.tsx @@ -83,6 +83,7 @@ import { import { FileTree, History } from "./explorers"; import { ActiveBox } from "./active-box"; import { ChatButton, ChatSidebar, useChat } from "./chat-sidebar"; +import { FilterBox, FilterButton, useFilter } from "./filter"; import { CommitView, FileView, RELATIONS_APART, SHOWN_APART } from "./git-views"; import { type Playback, usePlayback } from "./playback"; import { proseSegments } from "./prose"; @@ -423,8 +424,11 @@ function Lists({ onOpen: (key: string) => void; onList: (key: string) => void; }) { - // Narrows the Items pane to the items whose text holds it. - const [term, setTerm] = useState(""); + // Each list's filter, hidden until its search button shows it. + const itemsFilter = useFilter(); + const term = itemsFilter.term; + const filesFilter = useFilter(); + const commitsFilter = useFilter(); const listEntry = useDocument(requestPath(listKey)); // Until the picked collection's first page arrives, the collection it was picked from stays the // upper one, so the upper pane does not show the picked collection and then swap back. @@ -606,9 +610,25 @@ function Lists({ grow={filesGrow} sectionRef={filesSection} scrolls + actions={ + + } > + {filesFilter.shown ? ( + + ) : null} {filesKey === undefined ? null : ( - + )} {splitterAfter(filesSection)} @@ -621,9 +641,29 @@ function Lists({ grow={historyGrow} sectionRef={historySection} scrolls + actions={ + + } > + {commitsFilter.shown ? ( + + ) : null} {historyKey === undefined ? null : ( - + )} {splitterAfter(historySection)} @@ -659,21 +699,27 @@ function Lists({
Items - - {lower.items.length > 0 ? `${lower.items.length}${lower.next ? "+" : ""}` : ""} + + + {lower.items.length > 0 ? `${lower.items.length}${lower.next ? "+" : ""}` : ""} + + {lowerKey === undefined ? null : ( + + )}
- {lowerKey === undefined ? null : ( - setTerm(e.target.value)} + {itemsFilter.shown && lowerKey !== undefined ? ( + - )} + ) : null}
{lowerKey ?? ""}
{lowerKey === undefined ? ( @@ -718,6 +764,7 @@ function Explorer({ scrolls = false, grow, sectionRef, + actions, children, }: { title: string; @@ -730,6 +777,8 @@ function Explorer({ /** How many shares of the height the open sections divide this one takes. */ grow: number; sectionRef: RefObject; + /** Controls at the right of the header while the section is open, such as its filter's button. */ + actions?: ReactNode; children?: ReactNode; }) { const shown = open && unavailable === undefined; @@ -776,18 +825,23 @@ function Explorer({ aria-label={title} >
- +
+ + {shown && actions !== undefined ? ( + {actions} + ) : null} +
{unavailable === undefined ? null : (
{unavailable} diff --git a/packages/app-web/src/ui/trace/chat-sidebar.tsx b/packages/app-web/src/ui/trace/chat-sidebar.tsx index e3d6becd..3a5f150a 100644 --- a/packages/app-web/src/ui/trace/chat-sidebar.tsx +++ b/packages/app-web/src/ui/trace/chat-sidebar.tsx @@ -10,8 +10,7 @@ import { useState, } from "react"; import { mentionItems } from "../agent/mentions"; -import { collectionOf, FILE_TYPE, hrefOf, keyOf } from "./hal"; -import { docOf, requestPath, useDocument } from "./hal-store"; +import { useWorkspacePaths } from "./file-paths"; import { useSavedNumber } from "../saved-flag"; // The agent exists only in the dev server, so the chat is dev-only: in a production build @@ -245,42 +244,6 @@ export function ChatButton({ chat, onToggle }: { chat: Chat; onToggle: () => voi ); } -/** - * Every file's path at HEAD in the workspace `slug`, from the files API: the workspace's index - * names its file collection, whose top links `paths`. Nothing is asked for until `wanted`. A list - * the API cut short is used as it is, with a warning naming how many files it left out. - */ -function useFilePaths(slug: string, wanted: boolean): readonly string[] | undefined { - const indexKey = `/workspace/${slug}`; - const index = docOf(useDocument(wanted ? requestPath(indexKey) : undefined)); - const filesKey = - index === undefined - ? undefined - : collectionOf(indexKey, index).items.find((item) => item.data.type === FILE_TYPE)?.key; - const files = docOf( - useDocument(filesKey === undefined ? undefined : requestPath(`${filesKey}?limit=1`)), - ); - const pathsHref = files === undefined ? undefined : hrefOf(files, "paths"); - const listed = docOf( - useDocument(pathsHref === undefined ? undefined : requestPath(keyOf(pathsHref))), - ); - const paths = listed?.paths; - const list = useMemo( - () => - Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : undefined, - [paths], - ); - useEffect(() => { - if (listed?.truncated === true) - console.warn("chat mentions: the files API listed only some of the workspace's files", { - slug, - listed: list?.length, - total: listed.total, - }); - }, [listed, list, slug]); - return list; -} - /** * The agent's chat at the right of the page. At full width the rail beside it stays when it is * minimised; in a narrow window it is a drawer over the page, and its button is in the top bar. @@ -299,7 +262,7 @@ export function ChatSidebar({ const [width, setWidth] = useSavedNumber("labkit.trace.chat.width", CHAT_WIDTH); const [resizing, setResizing] = useState(false); const { folder, shown } = chat; - const paths = useFilePaths(chat.slug, chat.connected); + const paths = useWorkspacePaths(chat.slug, chat.connected, "chat mentions")?.paths; const cwd = folder.kind === "mapped" ? folder.cwd : undefined; const mentions = useMemo( () => (cwd === undefined || paths === undefined ? undefined : mentionItems(cwd, paths)), diff --git a/packages/app-web/src/ui/trace/explorers.tsx b/packages/app-web/src/ui/trace/explorers.tsx index e89a5451..12ba4c3c 100644 --- a/packages/app-web/src/ui/trace/explorers.tsx +++ b/packages/app-web/src/ui/trace/explorers.tsx @@ -15,8 +15,10 @@ import { hrefOf, keyOf, } from "./hal"; +import { filterItems } from "@labkit/ui"; import { ActiveBox } from "./active-box"; import { fileLook } from "./file-icons"; +import { usePathsOf } from "./file-paths"; import { docOf, requestPath, useDocument, usePages } from "./hal-store"; import { type LaneRow, lanesOf, widthOf } from "./lanes"; @@ -72,10 +74,13 @@ export function FileTree({ rootKey, openKey, onOpen, + term = "", }: { rootKey: string; openKey: string; onOpen: (key: string) => void; + /** While not blank, the tree is replaced by the files whose paths hold every word of it. */ + term?: string; }) { const [opened, setOpened] = useState>(new Set()); const list = useRef(null); @@ -92,12 +97,82 @@ export function FileTree({ }); return (
- - + + {term.trim() === "" ? ( + + ) : ( + + )}
); } +/** The most matches File Explorer's filter draws; it says how many more there are. */ +const MAX_MATCHES = 200; +/** A match's icon: the list of paths names no media type, and the page maps no extension to one. */ +const PLAIN = fileLook(undefined); + +/** + * The files at HEAD whose paths hold every word of `term`, from the files API's list of every path + * (`paths`), so a file in a folder not yet opened is found too. Each is a row with its folder and + * name, which opens it. + */ +function FileMatches({ + rootKey, + term, + openKey, + onOpen, +}: { + rootKey: string; + term: string; + openKey: string; + onOpen: (key: string) => void; +}) { + const listed = usePathsOf(rootKey, "file explorer filter"); + const matches = useMemo( + () => + listed === undefined + ? [] + : filterItems( + listed.paths.map((path) => ({ id: path, label: path })), + term, + ), + [listed, term], + ); + if (listed === undefined) return
Loading the files…
; + const shown = matches.slice(0, MAX_MATCHES); + return ( + <> +
+ {matches.length === 0 + ? `No file's path holds "${term.trim()}".` + : `${matches.length} of ${listed.total} files${matches.length > shown.length ? `; the first ${shown.length} shown` : ""}`} +
+ {shown.map(({ label: path }) => { + const key = listed.keyOf(path); + if (key === undefined) return null; + const slash = path.lastIndexOf("/"); + return ( + + ); + })} + + ); +} + function Directory({ dirKey, depth, @@ -227,10 +302,16 @@ export function History({ historyKey, openKey, onOpen, + term = "", }: { historyKey: string; openKey: string; onOpen: (key: string) => void; + /** + * While not blank, only the loaded commits whose id, message, author or branch names hold every + * word of it are drawn, without their lanes, under a line that says how many were searched. + */ + term?: string; }) { // Any page of the history names the timeline and the branches. const history = docOf(useDocument(requestPath(`${historyKey}?limit=1`))); @@ -275,6 +356,27 @@ export function History({ [timeline.items], ); const width = widthOf(rows); + const filtering = term.trim() !== ""; + const matching = useMemo( + () => + filtering + ? new Set( + filterItems( + timeline.items.map((item) => ({ + id: item.key, + label: String(item.data.name ?? ""), + detail: String(item.data.author ?? ""), + keywords: [ + String(item.id ?? ""), + ...(Array.isArray(item.data.branches) ? item.data.branches.map(String) : []), + ].join(" "), + })), + term, + ).map((item) => item.id), + ) + : undefined, + [filtering, timeline.items, term], + ); return (
@@ -290,8 +392,14 @@ export function History({ )} + {matching === undefined ? null : ( +
+ {`${matching.size} of the ${timeline.items.length} commits loaded match${timeline.next === undefined ? "" : "; load more to search further"}`} +
+ )} {timeline.items.map((item, i) => { - const row = rows[i]; + if (matching !== undefined && !matching.has(item.key)) return null; + const row = matching === undefined ? rows[i] : undefined; const id = String(item.id ?? ""); const names = Array.isArray(item.data.branches) ? item.data.branches.map(String) : []; const classes = ["commit-row", "timeline-row"]; diff --git a/packages/app-web/src/ui/trace/file-paths.ts b/packages/app-web/src/ui/trace/file-paths.ts new file mode 100644 index 00000000..f412c35e --- /dev/null +++ b/packages/app-web/src/ui/trace/file-paths.ts @@ -0,0 +1,77 @@ +import { useEffect, useMemo } from "react"; +import { collectionOf, FILE_TYPE, hrefOf, keyOf, linksOf } from "./hal"; +import { docOf, requestPath, useDocument } from "./hal-store"; + +/** Every file's path at HEAD, from the files API's `paths`, and the key of each path's file. */ +export interface FilePaths { + readonly paths: readonly string[]; + /** The key of the file at `path`, from the listing's templated `file` link. */ + readonly keyOf: (path: string) => string | undefined; + /** How many files the tree holds: more than `paths` when the API cut the list short. */ + readonly total: number; +} + +/** + * `template`, a link of RFC 6570's reserved form `…{+path}`, with `path` in it: each segment + * percent-encoded and the slashes kept. Undefined when the template has no `{+path}`. + */ +export function expandPath(template: string, path: string): string | undefined { + if (!template.includes("{+path}")) return undefined; + return template.replace("{+path}", path.split("/").map(encodeURIComponent).join("/")); +} + +/** + * The paths the file collection at `filesKey` links as `paths`, asked for once `filesKey` is + * known. A list the API cut short is used as it is, with a warning naming how many files it left + * out, for `reader`, which says who asked. + */ +export function usePathsOf(filesKey: string | undefined, reader: string): FilePaths | undefined { + const files = docOf( + useDocument(filesKey === undefined ? undefined : requestPath(`${filesKey}?limit=1`)), + ); + const pathsHref = files === undefined ? undefined : hrefOf(files, "paths"); + const listed = docOf( + useDocument(pathsHref === undefined ? undefined : requestPath(keyOf(pathsHref))), + ); + const result = useMemo((): FilePaths | undefined => { + if (listed === undefined || !Array.isArray(listed.paths)) return undefined; + const paths = listed.paths.filter((p): p is string => typeof p === "string"); + const template = linksOf(listed, "file")[0]?.href; + return { + paths, + keyOf: (path) => { + const href = template === undefined ? undefined : expandPath(template, path); + return href === undefined ? undefined : keyOf(href); + }, + total: typeof listed.total === "number" ? listed.total : paths.length, + }; + }, [listed]); + useEffect(() => { + if (listed?.truncated === true) + console.warn("file paths: the files API listed only some of the workspace's files", { + reader, + filesKey, + listed: result?.paths.length, + total: listed.total, + }); + }, [listed, result, reader, filesKey]); + return result; +} + +/** + * Every file's path at HEAD in the workspace `slug`: its index names its file collection, whose top + * links `paths`. Nothing is asked for until `wanted`. + */ +export function useWorkspacePaths( + slug: string, + wanted: boolean, + reader: string, +): FilePaths | undefined { + const indexKey = `/workspace/${slug}`; + const index = docOf(useDocument(wanted ? requestPath(indexKey) : undefined)); + const filesKey = + index === undefined + ? undefined + : collectionOf(indexKey, index).items.find((item) => item.data.type === FILE_TYPE)?.key; + return usePathsOf(filesKey, reader); +} diff --git a/packages/app-web/src/ui/trace/filter.tsx b/packages/app-web/src/ui/trace/filter.tsx new file mode 100644 index 00000000..8f7dc279 --- /dev/null +++ b/packages/app-web/src/ui/trace/filter.tsx @@ -0,0 +1,79 @@ +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { useState } from "react"; + +/** + * A list's filter, hidden until its button shows it. Hiding it empties it, so a hidden filter never + * narrows the list. + */ +export function useFilter(): { + shown: boolean; + term: string; + setTerm: (term: string) => void; + toggle: () => void; + hide: () => void; +} { + const [shown, setShown] = useState(false); + const [term, setTerm] = useState(""); + const hide = () => { + setShown(false); + setTerm(""); + }; + return { shown, term, setTerm, toggle: () => (shown ? hide() : setShown(true)), hide }; +} + +/** The button that shows or hides a list's filter: a search icon, with no border. */ +export function FilterButton({ + what, + shown, + onToggle, +}: { + /** What the list holds, for the button's name: "Filter the items". */ + what: string; + shown: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +/** The filter's text box, which takes focus when shown. Escape hides it, and so empties it. */ +export function FilterBox({ + what, + term, + setTerm, + onHide, +}: { + what: string; + term: string; + setTerm: (term: string) => void; + onHide: () => void; +}) { + return ( + setTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Escape") return; + e.preventDefault(); + onHide(); + }} + /> + ); +} diff --git a/packages/app-web/src/ui/trace/trace.css b/packages/app-web/src/ui/trace/trace.css index cb9f5ee2..06b54616 100644 --- a/packages/app-web/src/ui/trace/trace.css +++ b/packages/app-web/src/ui/trace/trace.css @@ -198,6 +198,20 @@ overflow: hidden; border-top: 1px solid var(--line); } +/* A section's header: the button that folds it, then its controls at the right while it is open. */ +.trace .explorer-headrow { + display: flex; + align-items: center; +} +.trace .explorer-headrow .explorer-head { + flex: 1 1 auto; + min-width: 0; +} +.trace .explorer-actions { + flex: none; + display: inline-flex; + padding-right: 10px; +} .trace .explorer-head { display: flex; align-items: center; @@ -372,6 +386,11 @@ flex: none; width: 16px; } +/* A line about File Explorer's filter: how many files match, or why none are drawn yet. */ +.trace .tree-note { + padding: 2px 6px 6px; + font-size: 11.5px; +} .trace .tree-name { min-width: 0; overflow: hidden; @@ -502,6 +521,11 @@ .trace .section-label .count { color: var(--text-faint); } +.trace .section-label .label-end { + display: inline-flex; + align-items: center; + gap: 6px; +} .trace .pane-path { font-size: 11px; margin: -4px 0 8px; diff --git a/packages/app-web/tests/git-api.test.ts b/packages/app-web/tests/git-api.test.ts index 1da69e63..9eb55357 100644 --- a/packages/app-web/tests/git-api.test.ts +++ b/packages/app-web/tests/git-api.test.ts @@ -88,6 +88,10 @@ describe("a workspace's files", () => { const { status, body } = await get("/workspace/alpha/paths"); expect(status).toBe(200); expect(body._links.index.href).toBe(`${ORIGIN}/workspace/alpha/file`); + expect(body._links.file).toEqual({ + href: `${ORIGIN}/workspace/alpha/file/{+path}`, + templated: true, + }); expect(body.paths).toContain("README.md"); expect(body.paths).toContain("src/a.ts"); // Directories are not listed, only the files in them. From 8a0a288630d884e99e040c21443c2439e2d318db Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 14:21:01 +0100 Subject: [PATCH 4/4] The app starts at a workspace picker and everything after is inside one workspace - GET /workspace lists the workspaces; the landing view reads it, and the app reads nothing from /collections. /collections/workspace stays for other API clients. - Picking a workspace opens /app/workspace/. In a workspace, Record Explorer lists its collections and the one picked below; before one is picked it is disabled and says so. ?list= is left out when it would name the workspace's own collections. - An address that names its workspace only in ?list= is rewritten to name it in its path. - The dev server's API proxy matches /workspace followed by a query. Co-Authored-By: Claude Opus 5.5 --- packages/app-web/docs/architecture.md | 4 +- packages/app-web/e2e/app.e2e.ts | 62 +++++++++----- packages/app-web/public/docs/api.md | 5 +- .../app-web/src/server/collections-handler.ts | 20 ++++- packages/app-web/src/server/handler.ts | 13 ++- .../app-web/src/ui/trace/TraceConsole.tsx | 83 +++++++++++++------ packages/app-web/src/ui/trace/trace.css | 41 +++++++++ .../app-web/src/ui/trace/workspace-picker.tsx | 48 +++++++++++ packages/app-web/tests/api.test.ts | 13 +++ packages/app-web/vite.config.ts | 3 +- 10 files changed, 235 insertions(+), 57 deletions(-) create mode 100644 packages/app-web/src/ui/trace/workspace-picker.tsx diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index db6d8699..7c620a75 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -36,7 +36,7 @@ HAL root (`Accept: application/hal+json` on `/`, or `/api` through Vite): { id: "labkit", _links: { self: { href: "/" }, start: { href: "/questions/1", name: "Q_1" }, questions: { href: "/questions" }, ... } } ``` -The Trace Console starts at `/collections/workspace`. +The web app starts at a list of the workspaces, read from `/workspace`. Everything after that is inside one workspace, so the app reads nothing from `/collections`. ### Files and history @@ -131,7 +131,7 @@ Text that is not UTF-8, or a binary file of a text type, gets the note. | Column | Shows | |--------|-------| -| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest. | +| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer. In a workspace, Record Explorer lists the workspace's collections and, below, the one picked (`?list=`), or the open collection and the collection it is listed in. Before a workspace is picked it is disabled and says so. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest. | | Centre | The graph, a card that folds: the resources opened in the workspace since its last reset and what they relate to, on a 3D canvas (`GraphView`). Clicking a node opens its resource. Below it, the open resource. Tabs: Overview (properties, then a card for each related resource, outbound then inbound), Debug, drawn as a wrench (the response; the events of the record, or of the acts that ran against the commit, each a line with chips for the records it names; and the commits those acts ran against). `?tab=` names the tab. The tabs and Reload sit at the right of the line with the resource's name. | | Right | In development, in a workspace: the agent's chat. At full width it slides open and shut beside a rail that stays; the rail's button expands or minimises it. In a window 860 px wide or narrower it is a drawer over the page from the right, opened by a button in the top bar. Its sessions open in the workspace's folder, which the dev server reads from the workspaces mapping file (`/agent-workspace/`). A workspace's session connects the first time the chat is shown in that workspace, and stays connected while the chat is hidden; a workspace the chat was never shown in has no session. Each workspace keeps its session for the life of the page: leaving it and coming back reopens that session. `?session=` names the session of the workspace in scope, so a reload reopens it. A workspace the file does not map has no chat, and the button is disabled with the reason. `/new` starts a session in place, and `/sessions` lists the sessions the agent keeps for the workspace's folder (ACP `session/list` by `cwd`), newest first, in a palette; picking one reopens it. `@` offers the workspace's files at HEAD (`/paths`); each file a message mentions is sent with it as a `resource_link` to the file in the workspace's folder (`file:///`), which the agent reads itself. The border at the chat's left edge moves by drag, by Left and Right (16 px), and by Home and End (to 320 px, and to 60% of the window); a double-click returns it to 400 px, and the browser keeps the width. | diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index 72965413..7eaf1b32 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -51,23 +51,34 @@ const item = (page: import("@playwright/test").Page, handle: string) => has: page.locator(".rid", { hasText: new RegExp(`^${handle}$`) }), }); const list = (path: string) => `list=${encodeURIComponent(path)}`; +// A workspace on the landing view, by its name. +const workspaceCard = (page: import("@playwright/test").Page, name: string) => + page.locator(".workspace-card", { + has: page.locator(".workspace-name", { hasText: new RegExp(`^${name}$`) }), + }); // The canvas's nodes are also a visually hidden list of buttons, for the keyboard, which is how // these tests pick a node: a canvas has no element to click. const node = (page: import("@playwright/test").Page, name: string | RegExp) => page.getByRole("list", { name: "Nodes in the graph" }).getByRole("button", { name }); test.describe("getting around", () => { - test("a browser at the root lands on the app, with the workspaces", async ({ page }) => { + test("a browser at the root lands on the app, which lists the workspaces and nothing above them", async ({ + page, + }) => { + const asked: string[] = []; + page.on("request", (req) => asked.push(new URL(req.url()).pathname)); await page.goto("/"); await expect(page).toHaveURL(/\/app\/$/); - await expect(row(page, "alpha")).toBeVisible(); - await expect(row(page, "beta")).toBeVisible(); + await expect(workspaceCard(page, "alpha")).toBeVisible(); + await expect(workspaceCard(page, "beta")).toBeVisible(); + await expect(page.getByRole("button", { name: "Record Explorer", exact: true })).toBeDisabled(); + expect(asked.filter((path) => path.startsWith("/collections"))).toEqual([]); }); test("workspace, then collection, then resource, by clicking", async ({ page }) => { await page.goto("/app/"); - await row(page, "alpha").click(); - await expect(page).toHaveURL(new RegExp(list("/workspace/alpha"))); + await workspaceCard(page, "alpha").click(); + await expect(page).toHaveURL(/\/app\/workspace\/alpha$/); await row(page, "Question").click(); await expect(page).toHaveURL(new RegExp(list("/workspace/alpha/question"))); @@ -84,7 +95,20 @@ test.describe("getting around", () => { await page.goto("/app/workspace/alpha/Q_1"); await page.getByRole("button", { name: "LabKit" }).click(); await expect(page).toHaveURL(/\/app\/$/); - await expect(page.getByText("Pick a type on the left")).toBeVisible(); + await expect(workspaceCard(page, "alpha")).toBeVisible(); + }); + + test("an address that names its workspace only in ?list= names it in its path instead", async ({ + page, + }) => { + await page.goto(`/app/?${list("/workspace/alpha/question")}`); + await expect(page).toHaveURL( + new RegExp(`/app/workspace/alpha\\?${list("/workspace/alpha/question")}$`), + ); + await expect(item(page, "Q_1")).toBeVisible(); + await page.goto(`/app/?${list("/workspace/alpha")}`); + await expect(page).toHaveURL(/\/app\/workspace\/alpha$/); + await expect(row(page, "Question")).toBeVisible(); }); test("the top bar has the LabKit name and no sidebar, history or filter controls", async ({ @@ -183,7 +207,7 @@ test.describe("getting around", () => { }) => { await page.goto("/app/workspace/alpha/question"); await expect(item(page, "Q_1")).toBeVisible(); - await expect(page.getByText("Pick a type on the left")).toBeVisible(); + await expect(page.getByText("Pick a collection in Record Explorer")).toBeVisible(); await item(page, "Q_1").click(); await expect(header(page)).toHaveText("Q_1"); await expect(page).toHaveURL(new RegExp(`/Q_1\\?${list("/workspace/alpha/question")}$`)); @@ -193,7 +217,7 @@ test.describe("getting around", () => { test("a collection picked from the upper pane loads below it, and the upper pane stays", async ({ page, }) => { - await page.goto(`/app/?${list("/workspace/alpha")}`); + await page.goto("/app/workspace/alpha"); await expect(row(page, "Question")).toBeVisible(); let release: () => void = () => {}; const held = new Promise((resolve) => { @@ -215,14 +239,14 @@ test.describe("getting around", () => { test("opening a resource of another type than the listed one closes the Items pane", async ({ page, }) => { - await page.goto(`/app/?${list("/workspace/alpha/question")}`); + await page.goto(`/app/workspace/alpha?${list("/workspace/alpha/question")}`); await item(page, "Q_1").click(); await expect(header(page)).toHaveText("Q_1"); await expect(item(page, "Q_2")).toBeVisible(); await page.locator(".card-head", { hasText: "LOE_1" }).click(); await expect(header(page)).toHaveText("LOE_1"); - await expect(page).toHaveURL(new RegExp(`${list("/workspace/alpha")}$`)); + await expect(page).toHaveURL(/\/app\/workspace\/alpha\/LOE_1$/); await expect(page.getByText("Pick a collection above to list its items.")).toBeVisible(); await expect(row(page, "Question")).toBeVisible(); }); @@ -238,7 +262,7 @@ test.describe("getting around", () => { }); test("a collection's next page is added on request", async ({ page }) => { - await page.goto(`/app/?${list("/workspace/alpha/question?limit=1")}`); + await page.goto(`/app/workspace/alpha?${list("/workspace/alpha/question?limit=1")}`); await expect(item(page, "Q_1")).toBeVisible(); await expect(item(page, "Q_2")).toHaveCount(0); await page.getByRole("button", { name: "Load more" }).click(); @@ -315,7 +339,7 @@ test.describe("the sidebar", () => { page, }) => { await page.goto("/app/"); - await expect(row(page, "alpha")).toBeVisible(); + await expect(workspaceCard(page, "alpha")).toBeVisible(); await expect(explorer(page, "File Explorer")).toBeDisabled(); await expect(explorer(page, "File Explorer")).toHaveAccessibleDescription( "Open a workspace to browse its files.", @@ -516,7 +540,7 @@ test.describe("the sidebar", () => { test("File and Source Explorers are enabled once Record Explorer lists a workspace, before any record is open", async ({ page, }) => { - await page.goto(`/app/?${list("/workspace/alpha")}`); + await page.goto("/app/workspace/alpha"); await expect(row(page, "Question")).toBeVisible(); await expect(explorer(page, "File Explorer")).toBeEnabled(); await expect(explorer(page, "Source Explorer")).toBeEnabled(); @@ -763,7 +787,7 @@ test.describe("the sidebar", () => { test("Record Explorer lists the workspace's records and acts, and leaves files and history to their own sections", async ({ page, }) => { - await page.goto(`/app/?${list("/workspace/alpha")}`); + await page.goto("/app/workspace/alpha"); await expect(row(page, "Question")).toBeVisible(); await expect(row(page, "Act")).toBeVisible(); await expect(row(page, "File")).toHaveCount(0); @@ -972,7 +996,7 @@ test.describe("the Items pane's open row", () => { test("in the Collections pane, a box marks the listed collection and slides to the next", async ({ page, }) => { - await page.goto(`/app/?${list("/workspace/alpha/question")}`); + await page.goto(`/app/workspace/alpha?${list("/workspace/alpha/question")}`); const outline = page.locator(".type-list .active-box.outline"); const top = async (locator: import("@playwright/test").Locator) => Math.round((await locator.boundingBox())?.y ?? -1); @@ -1332,7 +1356,7 @@ test.describe("workspaces are separate", () => { }); test("links inside a workspace stay inside it", async ({ page }) => { - await page.goto(`/app/?${list("/workspace/beta/question")}`); + await page.goto(`/app/workspace/beta?${list("/workspace/beta/question")}`); await item(page, "Q_1").click(); await expect(page).toHaveURL(/\/app\/workspace\/beta\/Q_1\?/); await expect(page.locator(".detail").getByText("beta question")).toBeVisible(); @@ -1435,7 +1459,7 @@ test.describe("the agent's chat in the Trace Console", () => { const box = await rail(page).boundingBox(); expect((box?.x ?? 0) + (box?.width ?? 0)).toBe(page.viewportSize()?.width); await page.goto("/app/"); - await expect(page.getByText("Pick a type on the left")).toBeVisible(); + await expect(workspaceCard(page, "alpha")).toBeVisible(); await expect(rail(page)).toHaveCount(0); }); @@ -1487,7 +1511,7 @@ test.describe("the agent's chat in the Trace Console", () => { await expect(page).toHaveURL(/[?&]session=fake-\d+/); await page.getByRole("button", { name: "LabKit" }).click(); await expect(page).toHaveURL(/\/app\/$/); - await row(page, "beta").click(); + await workspaceCard(page, "beta").click(); await expect(chatToggle(page)).toBeDisabled(); await expect(chatToggle(page)).toHaveAttribute("title", /maps no folder to the workspace beta/); expect(new URL(page.url()).searchParams.get("session")).toBeNull(); @@ -1573,7 +1597,7 @@ test.describe("the agent's chat in the Trace Console", () => { await page.getByRole("button", { name: "LabKit" }).click(); await expect(page).toHaveURL(/\/app\/$/); expect(new URL(page.url()).searchParams.get("session")).toBeNull(); - await row(page, "alpha").click(); + await workspaceCard(page, "alpha").click(); await expect(chatToggle(page)).toBeEnabled(); await expect(page).toHaveURL(new RegExp(`[?&]session=${named}`)); await expect.poll(() => sent).toEqual(["session/new", "session/load"]); diff --git a/packages/app-web/public/docs/api.md b/packages/app-web/public/docs/api.md index 87c1fdf3..36bdf00e 100644 --- a/packages/app-web/public/docs/api.md +++ b/packages/app-web/public/docs/api.md @@ -6,15 +6,16 @@ Machine-readable description: [/docs/openapi.json](/docs/openapi.json). Discover ## Workspaces -Everything below is scoped to a workspace, one research project's graph. There is no default workspace: `/collections/workspace` lists them and every other path names one, as `/workspace/{slug}`: +Everything below is scoped to a workspace, one research project's graph. There is no default workspace: `/workspace` lists them and every other path names one, as `/workspace/{slug}`: ``` +GET /workspace every workspace GET /workspace/{slug} a workspace's collections, one per node type GET /workspace/{slug}/{type} one collection GET /workspace/{slug}/{id} one entity ``` -- A workspace is a collection. `/collections/workspace` lists them, and each item's address is the workspace itself. It is offered at the API root only, since workspaces are not nested. +- A workspace is a collection. `/workspace` lists them, and each item's address is the workspace itself. `/collections/workspace` lists the same workspaces from the API root. - A slug that does not exist is a 404. A workspace never falls back to another one's data. - Links in a response keep you in the workspace you arrived by. `/workspace/{slug}/{id}` links to `/workspace/{slug}/…`. - Inside a workspace a collection and an entity share a path level. A collection name is lower case and a handle is not, so a name is never both. diff --git a/packages/app-web/src/server/collections-handler.ts b/packages/app-web/src/server/collections-handler.ts index a6bbdab0..a5a6ac05 100644 --- a/packages/app-web/src/server/collections-handler.ts +++ b/packages/app-web/src/server/collections-handler.ts @@ -186,7 +186,9 @@ async function listing( async function workspaceListing( req: Request, workspaces: Workspace[], - root: string, + self: string, + /** The index above the listing; the listing at `/workspace` has none. */ + up: string | undefined, ): Promise { const url = new URL(req.url); const origin = publicOrigin(req).origin; @@ -195,11 +197,11 @@ async function workspaceListing( const rows = workspaces.slice(offset, offset + limit + 1); const page = rows.slice(0, limit); - const self = `${root}/${WORKSPACE_SLUG}`; + const paging = pagingLinks(up ?? self, self, limit, offset, rows.length > limit); return halJson(req, { _links: { self: { href: `${self}?limit=${limit}&offset=${offset}` }, - ...linksOf(pagingLinks(root, self, limit, offset, rows.length > limit)), + ...linksOf(up === undefined ? paging.filter((l) => l.rel !== "index") : paging), }, offset, limit, @@ -252,7 +254,8 @@ export async function rootCollectionsHandler( const name = path.replace(/^\/collections\/?/, "").replace(/\/$/, ""); const root = `${publicOrigin(req).origin}/collections`; if (name === "") return index(req, root, [{ slug: WORKSPACE_SLUG, type: "Workspace" }]); - if (name === WORKSPACE_SLUG) return workspaceListing(req, await listWorkspaces(runtime), root); + if (name === WORKSPACE_SLUG) + return workspaceListing(req, await listWorkspaces(runtime), `${root}/${WORKSPACE_SLUG}`, root); return problem( 404, "Not Found", @@ -260,6 +263,15 @@ export async function rootCollectionsHandler( ); } +/** + * `/workspace`: every workspace, each addressed by its own index, `/workspace/{slug}`. The same + * workspaces as `/collections/workspace`, at the address its items sit under, with no index above. + */ +export async function workspacesHandler(req: Request, runtime: Runtime): Promise { + const self = `${publicOrigin(req).origin}/workspace`; + return workspaceListing(req, await listWorkspaces(runtime), self, undefined); +} + // `/workspace/{slug}` and `/workspace/{slug}/{type}`. `rest` is what follows the slug. export async function workspaceCollectionsHandler( req: Request, diff --git a/packages/app-web/src/server/handler.ts b/packages/app-web/src/server/handler.ts index ffce0b8f..59d6f485 100644 --- a/packages/app-web/src/server/handler.ts +++ b/packages/app-web/src/server/handler.ts @@ -3,6 +3,7 @@ import { isCollectionSlug, rootCollectionsHandler, workspaceCollectionsHandler, + workspacesHandler, } from "./collections-handler"; import { docsHandler } from "./docs-handler"; import { eventsHandler, isEventsPath } from "./events-handler"; @@ -63,9 +64,10 @@ export async function handle(req: Request, runtime: Runtime): Promise return withCors(req, await route(req, runtime)); } -// `/workspace/{slug}` is a workspace's collections, `/workspace/{slug}/{type}` one collection and -// `/workspace/{slug}/{handle}` one node. There is no default workspace: `/collections/workspace` -// lists them and every other path names one. +// `/workspace` lists the workspaces, `/workspace/{slug}` is a workspace's collections, +// `/workspace/{slug}/{type}` one collection and `/workspace/{slug}/{handle}` one node. There is no +// default workspace: `/workspace` (and `/collections/workspace`) list them and every other path +// names one. const WORKSPACE_PATH = /^\/workspace\/([^/]+)(\/.*)?$/; async function inWorkspace( @@ -84,6 +86,11 @@ async function route(req: Request, runtime: Runtime): Promise { const original = new URL(req.url).pathname; const path = original; + if (original === "/workspace" || original === "/workspace/") { + console.debug("request: workspaces", original); + return workspacesHandler(req, runtime); + } + const workspace = WORKSPACE_PATH.exec(original); if (workspace) { const slug = decodeURIComponent(workspace[1]!); diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx index 81387754..ba62985f 100644 --- a/packages/app-web/src/ui/trace/TraceConsole.tsx +++ b/packages/app-web/src/ui/trace/TraceConsole.tsx @@ -84,6 +84,7 @@ import { FileTree, History } from "./explorers"; import { ActiveBox } from "./active-box"; import { ChatButton, ChatSidebar, useChat } from "./chat-sidebar"; import { FilterBox, FilterButton, useFilter } from "./filter"; +import { WorkspacePicker } from "./workspace-picker"; import { CommitView, FileView, RELATIONS_APART, SHOWN_APART } from "./git-views"; import { type Playback, usePlayback } from "./playback"; import { proseSegments } from "./prose"; @@ -91,7 +92,8 @@ import { Badge, EventRow, PropRows, type Prose } from "./values"; import "./trace.css"; /** Where the left column starts: the list of workspaces. */ -const ENTRY = "/collections/workspace"; +/** The API's list of workspaces: the page's landing view lists it, and nothing else. */ +const WORKSPACES = "/workspace"; export type Tab = "overview" | "debug"; @@ -176,7 +178,8 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { ); const [graphOpen, setGraphOpen] = useState(true); const explored = useExplored(path); - const workspace = workspaceOf(path); + // A workspace's own index, `/workspace/`, is inside that workspace too. + const workspace = workspaceOf(`${path}/`); useStoreVersion(); const held = store.index(); @@ -187,7 +190,13 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { // Opening what is already open adds no history entry. Opening a resource of another type than // the listed collection's lists the collection that one is listed in instead, which closes the // Items pane: the reader is following relations, not reading down the list. - const shownList = openIsCollection ? path : list; + // The workspace in scope: the open resource's, or else that of the collection picked. + const scope = workspace ?? (list === undefined ? undefined : workspaceOf(`${list}/`)); + const workspaceIndex = scope?.replace(/\/$/, ""); + // Record Explorer lists the open collection, or else the one picked, or else the workspace's + // collections. The workspace's own index, open in the centre, lists what is picked under it. + const listKey = openIsCollection && path !== workspaceIndex ? path : (list ?? workspaceIndex); + const shownList = listKey; const open = (key: string) => { setDrawer(undefined); if (key === path) return; @@ -202,7 +211,8 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { void navigate({ to: "/$", params: { _splat: key.replace(/^\//, "") }, - search: (s) => ({ ...s, list: nextList }), + // The workspace's own collections are what Record Explorer lists when nothing is picked. + search: (s) => ({ ...s, list: nextList === workspaceIndex ? undefined : nextList }), }); }; const playback = usePlayback(path, workspace, open); @@ -210,7 +220,11 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { // as records are written, and its rows stay on screen until the new page arrives. const pickList = (key: string) => { store.load(requestPath(key), true); - void navigate({ to: ".", search: (s) => ({ ...s, list: key }), replace: true }); + void navigate({ + to: ".", + search: (s) => ({ ...s, list: key === workspaceIndex ? undefined : key }), + replace: true, + }); }; const pickTab = (next: Tab) => void navigate({ @@ -220,13 +234,20 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { }); const home = () => { setDrawer(undefined); - store.load(requestPath(ENTRY), true); + store.load(requestPath(WORKSPACES), true); void navigate({ to: "/$", params: { _splat: "" }, search: {} }); }; - const listKey = openIsCollection ? path : (list ?? ENTRY); - // The workspace of the open resource, or else of the collection Record Explorer lists. - const scope = workspace ?? workspaceOf(`${listKey}/`); + // An address that names its workspace only in `?list=` names it in its path instead. + useEffect(() => { + if (path !== "" || workspaceIndex === undefined) return; + void navigate({ + to: "/$", + params: { _splat: workspaceIndex.replace(/^\//, "") }, + search: (s) => ({ ...s, list: s.list === workspaceIndex ? undefined : s.list }), + replace: true, + }); + }, [path, workspaceIndex, navigate]); const chat = useChat({ slug: scope?.split("/")[2], expanded: narrow ? drawer === "chat" : chatShown, @@ -263,7 +284,7 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { ].join(" ")} workspace={scope} listKey={listKey} - root={listKey === ENTRY} + root={listKey !== undefined && listKey === workspaceIndex} openKey={path} held={held} expanded={narrow ? drawer === "items" : sidebarShown} @@ -295,6 +316,7 @@ export function TraceConsole({ path, list, tab, session }: TraceConsoleProps) { onPause={playback.pause} /> )} + {path === "" ? : null} /`, or undefined when none is open. */ workspace: string | undefined; - listKey: string; + /** The collection Record Explorer lists; undefined when no workspace is in scope. */ + listKey: string | undefined; root: boolean; openKey: string; held: HeldIndex; @@ -429,27 +452,31 @@ function Lists({ const term = itemsFilter.term; const filesFilter = useFilter(); const commitsFilter = useFilter(); - const listEntry = useDocument(requestPath(listKey)); + const listEntry = useDocument(listKey === undefined ? undefined : requestPath(listKey)); // Until the picked collection's first page arrives, the collection it was picked from stays the // upper one, so the upper pane does not show the picked collection and then swap back. const shown = useRef(undefined); - const upperKey = root - ? listKey - : docOf(listEntry) !== undefined - ? (indexOfList(listKey) ?? listKey) - : shown.current !== undefined && rowIn(shown.current, listKey) !== undefined - ? shown.current - : listKey; + const upperKey = + listKey === undefined || root + ? listKey + : docOf(listEntry) !== undefined + ? (indexOfList(listKey) ?? listKey) + : shown.current !== undefined && rowIn(shown.current, listKey) !== undefined + ? shown.current + : listKey; useEffect(() => { shown.current = upperKey; }); const lowerKey = upperKey === listKey ? undefined : listKey; // The row for the lower collection, which `list` may name with a query (`?limit=`), is lit. const lowerRow = lowerKey === undefined ? undefined : keyOf(lowerKey); - const upper = usePages(requestPath(upperKey), readCollection(upperKey)); + const upper = usePages( + upperKey === undefined ? undefined : requestPath(upperKey), + readCollection(upperKey ?? ""), + ); const lower = usePages( lowerKey === undefined ? undefined : requestPath(lowerKey), - readCollection(listKey), + readCollection(listKey ?? ""), ); // The Items pane follows the open resource: while it is of the type the pane lists and not among // the rows loaded, the next page is loaded, as long as the last one names a next page. @@ -499,6 +526,8 @@ function Lists({ ? `${slug} serves no ${what}.` : undefined; const filesUnavailable = unavailable(filesKey, "files"); + const recordsUnavailable = + listKey === undefined ? "Pick a workspace to browse its records." : undefined; const historyUnavailable = unavailable(historyKey, "history"); // The open sections, in order. A border that can be dragged sits after each but the last. const shares: Share[] = [ @@ -520,7 +549,7 @@ function Lists({ element: historySection, } : undefined, - recordsOpen + recordsOpen && recordsUnavailable === undefined ? { name: "Record Explorer", initial: RECORDS_SHARE, @@ -597,7 +626,7 @@ function Lists({