diff --git a/.gitignore b/.gitignore index 0e241a9dc3e..e1c07f00edc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ plans/ .pi/hf-sessions/ .pi/hf-sessions-backup/ collect.sh +docs-references diff --git a/package-lock.json b/package-lock.json index 2ccea7cc550..f0cf4de8766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -479,9 +479,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -489,9 +489,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -499,13 +499,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -524,14 +524,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1312,6 +1312,62 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lumis-sh/wasm-csharp": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-csharp/-/wasm-csharp-0.26.1.tgz", + "integrity": "sha512-hKA5jt1MQFE0GdcjTWRzpF298maMXgd3T+Jl5U/5cfcJb/vmZWUf5M9dhWR0bAF62gAwDj5FZZdOOpcJMfh2tA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lumis-sh/wasm-go": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-go/-/wasm-go-0.26.1.tgz", + "integrity": "sha512-0y/6e07WFHnLLrYq/ibdSnEU6+7vgcaY19s55+0FeBHlg8jvHEF3i1b1aKfJsSdWdkyIn2SV0WZ9+Tnjqtii2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lumis-sh/wasm-java": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-java/-/wasm-java-0.26.1.tgz", + "integrity": "sha512-B0S/3CYAobN5NtEmYJARZoTsnCSr0UTLqR9HqrZKouiY0UEUOwMTr3eRAbtMplRjBGvzZvDN2nlINY1DNac0Rg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lumis-sh/wasm-javascript": { + "version": "0.26.2", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-javascript/-/wasm-javascript-0.26.2.tgz", + "integrity": "sha512-bwvOg4bQxDGocUaCbj8u4nYffHxH2sJpxQc3IsNFoXRa7doJD7uGc1t3HS6SjPXNimsDMAnb0gAoST9yuWP6BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lumis-sh/wasm-python": { + "version": "0.26.2", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-python/-/wasm-python-0.26.2.tgz", + "integrity": "sha512-wevU3mGtXGWlBOkPL7fpM7MCvqjaP72mWmK3YbUDLNqRoZgRwIo9XC9UScFiMZWUQZ9QC9xZ6jlOUrRblwHyXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lumis-sh/wasm-rust": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-rust/-/wasm-rust-0.26.3.tgz", + "integrity": "sha512-HBYcTj6KBT7LBndkKRopR+zvRlfs/ILE9LPeoW3noyvV+En50P7swusvMNqaRhJOuqdxb/W01rlvnyDdX3NkeA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lumis-sh/wasm-tsx": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-tsx/-/wasm-tsx-0.26.1.tgz", + "integrity": "sha512-XD6wVR4NIEZ+l1IvHFJSVXz5Zvxr45W8rujM/LXE0wcsnFpb2uo2rbj8jqxfey5hq55d0XCmEUNabY11RITvXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lumis-sh/wasm-typescript": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@lumis-sh/wasm-typescript/-/wasm-typescript-0.26.1.tgz", + "integrity": "sha512-4YbWFEK3fYYmn6SaJbzVvk8CVwNqvDM2trUlQNn4M2xt/5iAYpkgTftHbWAoYB62AiY7m7uCUZaEntdmsjQVjQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@mariozechner/clipboard": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", @@ -2320,6 +2376,37 @@ "@vitest-evals/core": "0.15.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -2468,6 +2555,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3856,6 +3955,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -5251,6 +5362,12 @@ "node": ">= 8" } }, + "node_modules/web-tree-sitter": { + "version": "0.26.11", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.11.tgz", + "integrity": "sha512-Q5Dm3YTIXSXuH6FxX6RuzX2Qwpc4DPGiYMU87Wg5Z8OIStiQFiUex4zMDc0vBTw78EphaYJacncJghCHzbZptg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5397,61 +5514,6 @@ "undici-types": "~7.16.0" } }, - "packages/agent/node_modules/@vitest/coverage-v8": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "packages/agent/node_modules/ast-v8-to-istanbul": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", - "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "packages/agent/node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, "packages/agent/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -5544,12 +5606,21 @@ "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.9.0", + "web-tree-sitter": "0.26.11", "yaml": "2.9.0" }, "bin": { "pi": "dist/cli.js" }, "devDependencies": { + "@lumis-sh/wasm-csharp": "0.26.1", + "@lumis-sh/wasm-go": "0.26.1", + "@lumis-sh/wasm-java": "0.26.1", + "@lumis-sh/wasm-javascript": "0.26.2", + "@lumis-sh/wasm-python": "0.26.2", + "@lumis-sh/wasm-rust": "0.26.3", + "@lumis-sh/wasm-tsx": "0.26.1", + "@lumis-sh/wasm-typescript": "0.26.1", "@types/cross-spawn": "6.0.6", "@types/diff": "7.0.2", "@types/hosted-git-info": "3.0.5", @@ -5715,12 +5786,8 @@ "license": "MIT", "dependencies": { "@cheetahbyte/pi-ai": "^0.83.0-cheetahbyte.3", - "@cheetahbyte/pi-coding-agent": "^0.83.0-cheetahbyte.3", "@cheetahbyte/pi-protocol": "^0.83.0-cheetahbyte.3" }, - "bin": { - "server": "dist/legacy/cli.js" - }, "devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" @@ -5737,6 +5804,10 @@ "@cheetahbyte/pi-agent-core": "^0.83.0-cheetahbyte.3", "@cheetahbyte/pi-ai": "^0.83.0-cheetahbyte.3" }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "vitest": "4.1.9" + }, "engines": { "node": ">=22.19.0" } diff --git a/package.json b/package.json index 27a506eebc2..a66c39452d8 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build", - "build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build", + "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index a274a651ae5..acd1aec6adb 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -6,12 +6,14 @@ ### Breaking Changes -- Changed `Session` into the sole opened-session aggregate and replaced `SessionStorage`, `SessionRepo`, and concrete per-session persistence classes with a non-owning `SessionRepository` and caller-owned, async-disposable `SessionStore` instances. Create stores with `createInMemorySessionStore()` or `createJsonlSessionStore()`, compose them with `createSessionRepository({ store, search: createScanningSessionSearch(store) })`, and dispose the store after draining harness and session work. -- `Session` instances are now created by `SessionRepository`; direct construction from an independently supplied store and snapshot was removed. +- Replaced the legacy harness session model with the v4 lane-based `Session`, `SessionStorage`, and `SessionRepo` APIs, including durable operation records, global facts, shared sequence numbers, and tree-scoped lane views. +- Promoted the v2 session and `AgentHarness` API from the experimental entrypoint to the default package export and removed the experimental subpaths. +- Removed the legacy JSONL and in-memory repository APIs. `InMemorySessionRepo` is the reference v4 repository; JSONL v4 support will use the new `SessionRepo` contract. ### Added - Added bounded `Session.findEntriesOnBranch()` and `findEntryOnBranch()` queries with explicit traversal, filtering, ordering, and limit options. +- Added a compile-complete `AgentHarness` v2 scaffold; unfinished operation paths reject with `HarnessNotImplemented` while durable execution is implemented. ## [0.83.0] - 2026-07-29 diff --git a/packages/agent/docs/agent-harness.md b/packages/agent/docs/agent-harness.md deleted file mode 100644 index fdcffe24e41..00000000000 --- a/packages/agent/docs/agent-harness.md +++ /dev/null @@ -1,506 +0,0 @@ -# AgentHarness lifecycle - -`AgentHarness` is the orchestration layer above the low-level agent loop. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics. - -This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly. - -## Ultimate lifecycle goal - -Harness listeners and hooks should be able to close over the `AgentHarness` instance and call public harness APIs from any event where those APIs are documented as allowed. Those calls must not corrupt in-flight turn snapshots, reorder persisted transcript entries, lose pending writes, deadlock settlement, or leave the harness in the wrong phase. - -The intended rule is: - -- structural operations remain rejected while busy -- queue operations are accepted at documented turn-safe points -- runtime config setters update future snapshots without mutating the current provider request -- session writes made while busy are durably queued and flushed in deterministic order -- getters return latest harness config, not in-flight snapshots -- listeners/hooks currently receive no facade; if they close over the raw harness and call settlement APIs such as `waitForIdle()` during the active run, they can deadlock. A future facade should expose `runWhenIdle()` instead. - -`AssistantMessageStream` already decouples provider transport streaming, such as SSE or websocket reads, from downstream event consumption. The harness can therefore await listeners, extension hooks, persistence, and save-point work without blocking the provider transport reader or reintroducing ad hoc event queues. Lifecycle code should prefer explicit awaited sequencing at harness boundaries over fire-and-forget hook/event settlement. - -A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite. - -## Error handling - -The current split is: - -- low-level capabilities and helpers use `Result` where expected failures are contained and must not throw, such as `ExecutionEnv`, filesystem/shell operations, shell-output capture, resource loading, and compaction helpers -- high-level mutation/orchestration APIs such as `Session` and `AgentHarness` reject/throw instead of returning bare results that can be ignored -- public `AgentHarness` failures are normalized to `AgentHarnessError` where practical; subsystem errors are preserved as `cause` - -Harness events observe committed state. Public mutators validate required input and persistence before committing when practical, then await notifications. If a hook or subscriber fails after commit, the state change is not rolled back and the public method rejects with `AgentHarnessError` code `"hook"`. - -## State model - -The harness separates state into four categories. - -### Harness config - -Harness config is the latest runtime configuration set by the application or extensions: - -- model -- thinking level -- tools -- active tool names -- tool context source -- resources -- stream options -- system prompt or system prompt provider - -Getters return harness config. They do not return the snapshot used by an in-flight provider request. - -Setters update harness config immediately, including while a turn is in flight. Changes affect the next turn snapshot, not the currently running provider request. - -`setResources()` accepts concrete resources and emits `resources_update` on every call with shallow-copied current and previous resources. Applications own loading/reloading resources from disk or other sources and should call `setResources()` with new values. - -`getResources()` returns shallow-copied current resources. It is a live config read, not the last turn snapshot. - -### Turn snapshot - -A turn snapshot is the concrete state used for one LLM turn. It is created by `createTurnState()` and contains: - -- persisted session messages -- resolved resources -- resolved system prompt -- model -- thinking level -- all tools -- active tools -- resolved tool context -- stream options -- derived session id - -Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot. - -Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied. - -`toolContext` is application-defined and required when the configured tools require a non-`undefined` context. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use. - -Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot. - -### Built-in tools - -The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires the shared `ExecutionToolContext`, containing `env: ExecutionEnv`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package. `createBashTool()` accepts an async `prepare` hook that can mutate the command, working directory, environment, and environment-inheritance policy using the current tool context. - -### Session - -The session contains persisted entries only. Session reads return persisted state and do not include queued writes. - -`Session.buildContextEntries()` returns the compaction-aware entry sequence used for model context construction. `Session.buildContext()` derives runtime state from the full active branch, then projects those context entries to `AgentMessage[]`. Custom entries are omitted from model context by default; applications can pass `entryProjectors` to the `Session` constructor or `buildContext()` to project selected custom entries into messages. Applications can also pass stacked `entryTransforms`, which run after the default compaction transform, to filter or reorder context entries before projection. - -Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry. - -### Pending session writes - -Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`). - -Pending session writes are always persisted. They are flushed at save points, at operation settlement, and in failure cleanup. - -A public pending-writes/session-facade API is planned but not implemented yet. - -## Operation phases - -The harness has an explicit phase: - -```ts -type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; -``` - -Structural operations require `phase === "idle"` and synchronously set the phase before the first `await`: - -- `prompt` -- `skill` -- `promptFromTemplate` -- `compact` -- `navigateTree` - -Starting another structural operation while the harness is not idle rejects with `AgentHarnessError` code `"busy"`. - -The following operations are allowed during a turn where appropriate: - -- `steer` -- `followUp` -- `nextTurn` -- `abort` -- runtime config setters - -Phase/settlement semantics are still provisional and need a full lifecycle pass. - -## Turn execution - -`prompt`, `skill`, and `promptFromTemplate` follow the same flow: - -1. Assert idle and set phase to `"turn"`. -2. Create a turn snapshot with `createTurnState()`. -3. Derive invocation text from that snapshot. -4. Execute the turn with `executeTurn()`. - -`skill` and `promptFromTemplate` resolve their resource from the same snapshot that is passed to the turn. They do not resolve resources separately. - -`steer`, `followUp`, and `nextTurn` accept text plus optional images and create user messages internally. `nextTurn` messages are inserted before the new user message on the next user-initiated turn. - -Queue modes are live, not turn-snapshotted: - -- `getSteeringMode()` / `setSteeringMode()` -- `getFollowUpMode()` / `setFollowUpMode()` - -Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points. - -## Save points - -A save point occurs after an assistant turn and its tool-result messages have completed. - -At a save point the harness: - -1. flushes pending session writes after the agent-emitted messages for that turn -2. creates a fresh turn snapshot if the low-level loop may continue -3. applies the fresh context/model/thinking-level/stream-options/session-id state before the next provider request - -This lets model, thinking level, tool, resource, stream option, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. Because provider transport reading is already decoupled by `AssistantMessageStream`, save-point work and hook settlement can be awaited directly to keep transcript/session ordering deterministic. The loop callbacks are not recreated at save points. - -The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary: - -- `"off"` -> `undefined` -- all other thinking levels pass through - -No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review. - -If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation rejects with `AgentHarnessError` and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message. - -## Hooks and events - -The target hook system is described in [hooks.md](./hooks.md). - -Summary: - -- `AgentHarness` emits typed hook events and consumes typed results. -- A single hooks implementation owns registration, cleanup, provenance, and result reducers. -- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result. -- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events. -- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types. -- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes. - -Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing. - -### Summarization retry events - -When the harness is configured with a retry policy, generated compaction and branch-summary requests emit retry lifecycle events for transient provider errors: - -- `retry_scheduled`: a retry was scheduled. Includes `operation: "compaction" | "branch_summary"`, `attempt`, `maxAttempts`, `delayMs`, and `errorMessage`. -- `retry_attempt_start`: the backoff delay completed and the retried summarization request is starting. Includes `operation`. -- `retry_finished`: the retry loop finished after success, exhaustion, or abort. Includes `operation`. - -These events are observational and do not accept hook results. - -## Planned session facade - -Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes. - -Planned read semantics: - -- reads delegate to persisted session state -- reads do not include queued pending writes - -Planned write semantics: - -- idle: persist immediately -- busy: enqueue as pending session writes - -A planned diagnostics API may expose pending writes explicitly: - -```ts -getPendingWrites(): readonly PendingSessionWrite[] -``` - -Agent-emitted messages are persisted on `message_end` to preserve transcript ordering. Pending extension/session writes flush after those messages at save points. - -## Abort - -Abort is allowed during a turn. It aborts the low-level run and clears steering/follow-up queues. - -Abort does not clear `nextTurn` messages. Messages queued with `nextTurn()` survive abort and are inserted before the user message on the next user-initiated turn. - -Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup. - -Abort barrier semantics still need an audit. - -## Compaction and tree navigation - -Compaction and tree navigation are structural session mutations. - -They are allowed only while idle and are not queued. They operate on persisted session state. The next prompt creates a fresh turn snapshot. - -Branch summary generation is part of the tree navigation operation. - -Auto-compaction and retry decision points are not implemented in `AgentHarness` yet. - -## Test organization - -Harness tests should stay focused by area instead of growing one large catch-all file. - -Current structure: - -- `packages/agent/test/harness/agent-harness.test.ts`: core lifecycle and public API behavior. -- `packages/agent/test/harness/agent-harness-stream.test.ts`: stream options and provider hook semantics. - -Preferred future structure: - -- `agent-harness-resources.test.ts`: resource snapshot/loading semantics. -- `agent-harness-tools.test.ts`: tool registry getters, active-tool semantics, and update events. -- `agent-harness-lifecycle.test.ts`: phase/save-point/settled/reentrancy behavior. - -Use the `pi-ai` faux provider (`registerFauxProvider`, `fauxAssistantMessage`) for deterministic harness/provider tests. Faux response factories can inspect `StreamOptions`, invoke `options.onPayload`, and return scripted assistant messages without real provider APIs or network access. - -Harness coverage is configured separately from the default package test run: - -```bash -npm run test:harness -npm run coverage:harness -``` - -`coverage:harness` runs `test/harness/**/*.test.ts` and reports coverage for `src/harness/**/*.ts` plus the non-harness runtime files it directly exercises (`src/agent.ts` and `src/agent-loop.ts`) into `coverage/harness`. Type-only dependencies such as `src/types.ts` are not included because they have no meaningful runtime coverage. - -## Implementation todo - -This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom. - -### 1. Add explicit tool registry read/update semantics - -Status: In progress - -Done: - -- Added `setTools(tools, activeToolNames?)`. -- Added `setActiveTools(toolNames)`. -- Invalid active tool names reject with `AgentHarnessError`. -- Added generic app tool and context shapes via `AgentHarness`. -- Exported `QueueMode` from core types. -- Added `AgentHarnessOptions.steeringMode` and `followUpMode`. -- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`. -- Added `getTools()` and `getActiveTools()`. -- Added `tools_update` observability events, including active-tool-only updates. -- Active tool changes are persisted as branch-scoped `active_tools_change` entries. -- Duplicate tool names and duplicate active tool names reject. - -Remaining: - -- None. - -Notes: - -- Observability design: [observability.md](./observability.md) - -### 2. Design per-`AgentHarness` model registry - -Status: Planned - -Done: - -- Current `setModel()` behavior is preserved. - -Remaining: - -- Decide how applications supply the model registry. -- Decide whether the harness stores concrete `Model` objects, model references, or both. -- Validate model selection against the registry. -- Define model change semantics during active turns and save points. - -### 3. Full `AgentHarness` lifecycle/state pass - -Status: In progress - -Done: - -- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`. -- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`. -- Added explicit `phase` in place of boolean idle state. -- Save points refresh context, model, thinking level, stream options, and session snapshot state. -- Pending session writes use session-entry shapes without generated fields. -- Pending session writes flush at save points, settlement, and failure cleanup. -- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images. -- `nextTurn` messages are inserted before the new user prompt. -- Structural compaction/tree operations restore phase with `finally`. -- Public harness failures normalize subsystem causes to `AgentHarnessError`. -- Pending session writes flush one-by-one and are not dropped on failure. -- Queue drains roll back if queue-update notification fails. -- `message_end` persistence happens before subscriber notification. -- `abort()` signals cancellation before notifications and still waits for idle through notification errors. -- Idle model/thinking/tool updates validate and persist before committing in-memory state. -- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen. - -Remaining: - -- Finalize phase/idle semantics. -- Audit whether `settled` can fire too early. -- Make session writes inside `settled` callbacks deterministic. -- Audit follow-up behavior around `agent_end`. -- Implement auto-compaction decision point. -- Implement retry handling. -- Verify `before_agent_start` hook semantics against coding-agent. -- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets. -- Document or change runtime config event timing while busy. -- Audit `abort()` barrier semantics. - -### 4. Implement generic hook/event extension mechanism - -Status: Designed in [hooks.md](./hooks.md), not implemented - -Done: - -- Removed `AgentHarnessContext`. -- Hooks receive only event payloads. -- `emitHook(event)` derives the hook type from `event.type`. -- Provider request/payload hooks have ordered transform semantics. - -Remaining: - -- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation. -- Move result chaining out of `AgentHarness` into reducer functions. -- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics. -- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks. -- Define the initial harness/context facades exposed through hook context. -- Preserve current provider hook behavior, including stream option patch deletion semantics. -- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage. - -Notes: - -- Hook design: [hooks.md](./hooks.md) - -### 5. Spike semi-durable harness/session recovery - -Status: Planned - -Done: - -- Wrote durability design: [durable-harness.md](./durable-harness.md) - -Remaining: - -- Decide whether session owns all durable harness state or whether any sidecars are needed for large blobs. -- Define durable entries for queues, pending writes, operations, turns, provider requests, and tool calls. -- Define resume requirements for app-provided tools, models, extensions, resources, hooks, and auth providers. -- Define conservative recovery policy for unfinished agent turns, provider requests, tool calls, compaction, and tree navigation. -- Prototype reducer-based recovery from session entries. -- Decide whether interrupted operations append user-visible messages or only internal operation entries. - -Notes: - -- Provider streams are not resumable; recovery should restart from durable boundaries or mark operations interrupted. -- Unfinished tool calls are unsafe to retry unless tools declare idempotent/retry-safe behavior. - -### 6. Final lifecycle hardening suite - -Status: Planned - -Done: - -- None. - -Remaining: - -- Add broad listener/hook reentrancy tests across relevant events. -- Test runtime config setters from low-level lifecycle events and harness events. -- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options. -- Test resource/tool/model/thinking/stream-option updates during active turns and save points. -- Test session writes from listeners and hooks, including `settled` writes. -- Test queue operations from turn events, tool events, and provider hooks. -- Test rejected structural operations while busy. -- Test abort from listeners/hooks. -- Test getter behavior during active operations. -- Test deterministic ordering of agent-emitted messages and pending listener writes. -- Test no deadlocks when async listeners call harness APIs and await them. -- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation. - -### 7. Later coding-agent migration plan - -Status: Planned - -Done: - -- None. - -Remaining: - -- Map coding-agent resources to sourced loaders. -- Keep app-level resource dedupe/provenance outside the harness. -- Adapt extension loading to the future hook/session facade. -- Preserve UI/session behavior outside core. -- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks. - ---- - -## Completed implementation todo - -### 8. Remove `Agent` dependency from `AgentHarness` - -Status: Done - -Done: - -- `AgentHarness` calls `runAgentLoop()` directly. -- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots. -- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping. - -Remaining: - -- None. - -Notes: - -- Broader listener/hook reentrancy coverage is tracked in item 6. - -### 9. Finish curated provider/stream configuration - -Status: Done - -Done: - -- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`. -- Stream options, headers, metadata, and derived session id are snapshotted per turn. -- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop. -- `getApiKeyAndHeaders()` resolves credentials per provider request. -- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented. -- Stream option patching supports explicit field deletion and ordered hook chaining. -- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior. - -Remaining: - -- None. - -### 10. Complete low-level `Result` cleanup - -Status: Done - -Done: - -- Added generic `Result` plus helpers. -- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations. -- Split filesystem and shell capabilities. -- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports. -- Added `ExecutionEnv.appendFile()` for streaming append use cases. -- Updated skill and prompt-template loaders to consume `ExecutionEnv` results. -- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`. -- Removed `NodeExecutionEnv` from browser-safe root exports. -- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling. -- Converted compaction and branch-summary helpers to typed result returns. -- Added `readTextLines()` so JSONL metadata loading reads only the header line. -- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful. -- Mapped filesystem errors crossing the session boundary to typed `SessionError`. -- Added typed branch-summary errors and cause-aware public harness error normalization. -- Resource loaders report structured diagnostics for non-`not_found` filesystem failures. -- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill. - -Remaining: - -- None. - -Notes: - -- Keep low-level capability/helper APIs non-throwing where they return `Result`. -- Keep session storage/repo/session APIs throwing typed `SessionError`. -- Keep public structural harness failures normalized to `AgentHarnessError`. -- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points. -- Audit generic harness utilities for Node globals as APIs are added. -- Audit package exports so browser/generic imports do not pull Node-only modules. -- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve. diff --git a/packages/agent/docs/durable-harness.md b/packages/agent/docs/durable-harness.md deleted file mode 100644 index ce5aa46d40e..00000000000 --- a/packages/agent/docs/durable-harness.md +++ /dev/null @@ -1,212 +0,0 @@ -# Durable AgentHarness and session design - - - -Durable AgentHarness / session design notes. - -## Framing - -A fully durable `AgentHarness` is not realistic by itself because important dependencies are runtime JS supplied by the host app: - -- tool implementations -- model/auth providers -- extensions and hook handlers -- resource loaders -- system-prompt callbacks/modifiers - -Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations. - -The practical target is a semi-durable harness: - -- session is the durable append-only state tree -- harness persists the state it owns into session entries -- the host app is responsible for recreating compatible non-persistable dependencies on resume -- recovery restarts from durable boundaries, not from an in-flight provider stream - -## Session owns durable state - -Treat session as all durable agent state, not just transcript history. - -Existing session state already includes harness state: - -- model changes -- thinking-level changes -- active-tool changes -- leaf entries -- labels -- compactions and branch summaries -- custom messages and custom entries - -That suggests continuing with one durable session log rather than adding harness sidecars. Sidecars may still be useful for large blobs, but the session entry should remain the source-of-truth reference. - -## What the app must provide on resume - -The app must recreate compatible runtime dependencies: - -- model registry / model objects -- tool registry -- extension set, versions, and ordering -- resource loaders -- system prompt providers/hooks -- auth providers -- app-specific hooks - -Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself. - -## Runtime configuration and restore - -Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous. - -A future async builder/factory should own durable restore: - -```ts -const harness = await AgentHarness.builder() - .env(env) - .session(session) - .model(defaultModel) - .tools(runtimeTools) - .defaultActiveTools(["read", "edit"]) - .restore({ missingActiveTools: "fail" }); -``` - -`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction. - -For active tools: - -- `active_tools_change` entries are branch-scoped durable config. -- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied. -- Active tool names must be unique. -- Tool registry names must be unique. -- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later. -- Concrete tools are never restored from session; the host app must provide compatible tools. - -## What harness should persist - -Minimum useful durability entries: - -- branch-scoped active tool names -- queued steer/followUp/nextTurn messages -- queue consumption tied to a turn -- pending session writes accepted during active operations -- pending write application status -- operation start/finish/interruption -- turn start/finish -- provider request start/finish, if needed for recovery diagnostics -- tool call start/finish, if we want safe tool recovery - -Potential entries: - -```ts -type DurableHarnessEntry = - | QueueEnqueuedEntry - | QueueConsumedEntry - | PendingWriteEnqueuedEntry - | PendingWriteAppliedEntry - | OperationStartedEntry - | OperationFinishedEntry - | OperationInterruptedEntry - | TurnStartedEntry - | TurnFinishedEntry - | ProviderRequestStartedEntry - | ProviderRequestFinishedEntry - | ToolCallStartedEntry - | ToolCallFinishedEntry; -``` - -Every accepted mutation must be durable before the public API resolves. - -## Recovery model - -On startup: - -1. Host app registers tools/models/extensions/resources/auth/hooks. -2. Harness opens session. -3. Harness reduces session entries into: - - current leaf - - conversation branch - - harness config, including active tool names - - queues - - pending writes - - active operation/turn/tool state -4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry. -5. Harness reconciles unfinished operation state. - -Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted. - -## Recovery policies - -Default conservative policy: - -- unfinished agent turn: mark interrupted, preserve durable queues/pending writes, return idle -- unfinished provider request: mark interrupted; do not retry automatically -- unfinished tool call: append interrupted/error tool result; retry only if the tool declares retry-safe/idempotent -- unfinished compaction: rerun if no compaction entry exists -- unfinished branch summary/tree navigation: rerun/apply missing summary or leaf entries if safe - -Optional policy: - -```ts -recovery: "mark_interrupted" | "retry_unfinished" -``` - -`retry_unfinished` must be guarded around non-idempotent tool calls. - -## Critical scenarios - -### Queues - -- Crash before `queue_enqueued`: message was not accepted. -- Crash after `queue_enqueued`: message is restored. -- Crash after queue drain but before durable turn record: risk of loss/duplication. -- Required invariant: consumed queue IDs must be recorded in `turn_started` or equivalent before they are considered consumed. - -### Pending writes - -- Crash before `pending_write_enqueued`: write was not accepted. -- Crash after enqueue before apply: recovery applies it. -- Crash after apply before applied marker: deterministic target entry IDs let recovery detect the entry already exists and mark it applied. - -### Agent loop turn - -- Crash before provider request: retry or mark interrupted. -- Crash during provider request: mark interrupted by default. -- Crash after provider response before assistant message persisted: response is lost unless provider result was journaled. -- Crash after assistant message persisted: recover from durable message. - -### Tool calls - -- Crash after tool call starts but before result: external side effects may already have happened. -- Default recovery should not rerun non-idempotent tools. -- Tool calls need stable IDs and retry-safety metadata for automatic recovery. - -### Compaction - -- Crash before summary generation: rerun preparation/summary. -- Crash after generated summary but before compaction entry: rerun unless summary was journaled. -- Crash after compaction entry: operation is complete; append finish marker if missing. - -### Branch summary / tree navigation - -- Crash before summary: rerun or mark interrupted. -- Crash after summary entry before leaf entry: append missing leaf entry. -- Crash after leaf entry: operation is complete; append finish marker if missing. - -## Minimum viable spike - -1. Add durable queue entries. -2. Add durable pending write entries with deterministic target IDs. -3. Add operation start/finish/interrupted entries. -4. Add turn start with consumed queue IDs. -5. Recover by reducing the session log. -6. Mark unfinished agent turns interrupted by default. -7. Rerun unfinished compaction/tree operations only when no final entry exists. -8. Do not retry unfinished tool calls unless tool metadata says retry-safe. - -## Open questions - -- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs? -- Should resolved system prompt text be snapshotted per turn for audit/debug? -- Do we require strict dependency ID/version matching on resume? -- How much provider request data should be journaled? -- Should recovery append user-visible assistant interruption messages or only internal operation entries? -- Should storage support truncating a final partial JSONL line during recovery? diff --git a/packages/agent/docs/harness-v2.md b/packages/agent/docs/harness-v2.md index f798900ac89..8b4ad2100f9 100644 --- a/packages/agent/docs/harness-v2.md +++ b/packages/agent/docs/harness-v2.md @@ -40,6 +40,7 @@ The harness executes runs against one session. The session holds four kinds of s - **Provider stream resumption.** Partial streams are never persisted. An interrupted streaming request is retried or abandoned. Deferred requests are different and in scope: the provider returns a handle at once and serves the result later (e.g. `background: true` on a Responses API, batch APIs). pi-ai returns an assistant message with stop reason `deferred` that carries the handle; it is persisted like any assistant message. Redeeming the handle appends a normal assistant message. Recovery sees the unredeemed handle and fetches instead of paying for a new request. - **Multiple writers.** Two processes on one session are out of scope. The serving layer routes all traffic for a session to the process that holds its harness. Lanes cover the workloads that look like multi-writer: parallel threads over shared history. - **Replication.** A session lives in one place. Coordination-free sync of diverging copies is a different design. Nothing forecloses it later; section 19 records why. +- **Coding-agent migration.** `packages/coding-agent` remains on its current runtime and is not modified by this implementation plan. Compatibility means the new JSONL repository can read supported coding-agent v3 files; it does not mean coding-agent is switched to `AgentHarness` here. ## 2. What a session is @@ -114,7 +115,7 @@ An operation is accepted before it executes. Acceptance is durable: after a cras A run is a sequence of turns. A turn is one assistant step plus the complete tool batch requested by that assistant message. -A step is a retryable unit of work inside an operation: produce an assistant message, a compaction summary, or a branch summary. A step may make zero, one, or several provider requests. A failed attempt retries the same step; the attempt count is durable and survives restarts. A deferred provider request suspends an assistant step: the handle arrives inside a persisted assistant message, the lane suspends, and redemption later appends the real result (section 1). +A step is a retryable unit of work inside an operation: produce an assistant message, a compaction summary, or a branch summary. A step may make zero, one, or several provider requests. A failed attempt retries the same step; the attempt count is durable and survives restarts. A deferred provider request ends an assistant step: the handle arrives inside a persisted assistant message that closes the step, the operation suspends, and redemption later appends the real result (section 1). Each tool call that starts an effect is also a step. `tool_started` opens it; its tool-result entry closes it. A parallel batch holds several open tool steps at once; their effects run concurrently and finalize in source order (section 14). @@ -178,7 +179,7 @@ Part II is backend-neutral. It defines the records a lane writes, when it writes > Before an effect: write an intent record that names what will happen and the ids it will produce. After the effect: append the result as an entry with exactly those ids. -There is no multi-record atomicity and none is needed. Each record and each entry is durable alone. A crash between intent and result leaves the intent unfulfilled; recovery decides per intent type: complete it, retry it, or close it with a synthetic result. An intent is fulfilled if and only if an entry with its provisioned id exists. The entry can itself name the next durable state: an assistant entry with `stopReason: "deferred"` fulfills its attempt's provisioned append but suspends the step rather than closing it. A provisioned id that exists with different content is corruption. +There is no multi-record atomicity and none is needed. Each record and each entry is durable alone. A crash between intent and result leaves the intent unfulfilled; recovery decides per intent type: complete it, retry it, or close it with a synthetic result. An intent is fulfilled if and only if an entry with its provisioned id exists. The entry can itself name the next durable state: an assistant entry with `stopReason: "deferred"` fulfills its attempt's provisioned append and closes the step; what stays outstanding is the operation — the persisted handle awaits redemption (section 6). A provisioned id that exists with different content is corruption. ### Provisioned ids @@ -194,7 +195,7 @@ type ProvisionedEntry = ### Record catalog -Every record belongs to one lane's operation log. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. `queue_enqueued` for the next-run queue is the one record without `runId`; it is consumed by the lane's next run. +Every record belongs to one lane's operation log. Records that belong to an operation carry `runId`: the id of that operation's `operation_started` record. Next-run queue records (`queue_enqueued` and their `queue_cancelled`) and standalone `adjustment` usage records carry no `runId`. ```ts interface RecordBase { @@ -249,7 +250,6 @@ interface OperationStartedRecord extends RecordBase { interface AbortRequestedRecord extends RecordBase { type: "abort_requested"; runId: string; - reason: "user" | "shutdown"; } // Closes the operation. failed = orderly durable failure (for example, @@ -263,11 +263,11 @@ interface OperationFinishedRecord extends RecordBase { } // Written before each attempt at a retryable step. Marks: we are about to -// do this, for the n-th time. Steps are logged only because they are +// do this, for the n-th time. Steps are logged because they are // retryable: the durable count caps retries across restarts — a // crash-restart loop cannot reset it. One record per attempt; one attempt -// may make zero or several provider requests (hook-supplied summaries make -// none, split-turn compaction makes two). Deferred results need no extra +// may make zero or several provider requests (split-turn compaction +// makes two). Deferred results need no extra // record: the handle lives in the persisted assistant entry (section 1). interface StepAttemptRecord extends RecordBase { type: "step_attempt"; @@ -432,7 +432,7 @@ R usage E assistant message ``` -Every provider request settles with a `usage` record (section 5); the other traces omit them for brevity. +Every provider request settles with a `usage` record (section 5); the other traces omit them for brevity. Per-request hooks (`transform_context`, `before_request`, `after_response`) run inside every request and are omitted everywhere; Tier B records them (section 20). Crash during backoff: restore counts two attempts; resume starts attempt 3. The count never resets. Retryable errors below the cap are never appended as entries. Attempts exhausted — or a non-retryable terminal error — appends an assistant message with the error, then `operation_finished` failed: @@ -474,7 +474,7 @@ R step_attempt step assistant, attempt 1 — new step E assistant message ``` -**One recovery per conversational input.** An overflow compaction may start only when no overflow-reason compaction `step_attempt` is newer than this run's newest consumed conversational message (prompt, steering, or follow-up). A second recoverable response inside that window appends the give-up error entry and fails the run through the drain path — a `length` response never resets the guard; only consumed conversational input does. This bounds the compact-and-retry loop at one attempt per user action. A `before_compaction` decline or an empty compaction preparation for reason `overflow` is equally terminal: without compaction the request cannot fit. +**One recovery per conversational input.** An overflow compaction may start only when no overflow-reason compaction `step_attempt` is newer than this run's newest consumed conversational message (prompt, steering, or follow-up). A second recoverable response inside that window appends the give-up error entry and fails the run through the drain path — a `length` response never resets the guard; only consumed conversational input does. This bounds the compact-and-retry loop at one attempt per user action. A `before_compaction` decline or an empty compaction preparation for reason `overflow` is equally terminal: without compaction the request cannot fit. A hook-supplied overflow compaction writes its compaction `step_attempt` before the entry so the guard counts it — the one hook-supplied summary that writes an attempt record. Per crash site: @@ -623,7 +623,7 @@ E assistant message stop reason deferred, carries the handle ... hours pass, maybe a different process ... resume() newest entry on the lane's path is a deferred assistant message with no successor - → the attempt is outstanding, redeem it + → the handle is unredeemed, redeem it fetchDeferred(model, handle) model and handle from that entry E assistant message the real result run continues normally @@ -633,7 +633,7 @@ The suspended lane is indistinguishable from a crashed one in storage: an open o Each `resume()` performs one fetch. Three outcomes: -- **pending** — the provider returns stop reason `deferred` again. Nothing is written; the lane re-suspends. Poll cadence is application policy. +- **pending** — the provider returns stop reason `deferred` again. Nothing but a possible `usage` record is written (section 15); the lane re-suspends. Poll cadence is application policy. - **ready** — a normal assistant message. It is appended as the successor and the run continues. - **terminal** — the provider returns stop reason `error` (expired, unknown, consumed), or the fetch itself rejects; the harness converts a rejection to the same error-message form. The message is appended and the run finishes failed. Redemption failure never starts an automatic replacement request; steering or follow-up input already accepted for this run can still start a later turn. @@ -647,22 +647,28 @@ Deferred assistant messages carry a handle, not content; they project to nothing Opening a session restores every lane independently. Restore reads; it never appends and never starts effects. -Per lane, one question: does the operation log hold an `operation_started` without a matching `operation_finished`? No: the lane is idle. Its only remaining state is pending next-run queue items. Next-run messages can be enqueued at any time; only the acceptance of a run consumes them — compaction and navigation pass over the queue. Pending items are therefore the `queue_enqueued` records after the lane's most recent run-kind `operation_started` whose provisioned entries do not exist and that no `queue_cancelled` retracts; nothing older can still be pending. Items a run captured are listed in its intent's `initialMessages`, so a captured-but-unappended item is completed by that run's recovery and is never offered to the next run. Yes: the lane is suspended, and its state is reduced from two bounded reads: +Recovery starts with indexed discovery, not a full log scan: -1. **The lane's records** since that `operation_started`. Everything after the finish of the previous operation is irrelevant history. -2. **The lane's own entries**: the path from its leaf back to the operation's anchor (`sourceLeafId`). These are exactly the entries this operation appended. +1. `findOpenOperations(lane, { limit: 2 })` returns unfinished `operation_started` records newest first. Zero means idle, one means suspended, and two means corruption. Backends must answer this from replayed/indexed operation state; callers cannot infer it from only the newest start. +2. For an idle lane, one indexed query finds the newest run-kind `operation_started`, then filtered `queue_enqueued` / `queue_cancelled` queries above it reconstruct pending `nextRun` items. With no prior run, the same type-filtered queries read only pre-run queue state; unrelated usage adjustments are never scanned. +3. For a suspended lane, the open operation selects two bounded payload reads: + - **The lane's records** since that `operation_started`. Everything after the finish of the previous operation is irrelevant history. + - **The lane's own entries**: the path from its leaf back to the operation's anchor (`sourceLeafId`). These are exactly the entries this operation appended. -Both reads are bounded by the size of the open operation, not by the size of the session or the activity of other lanes. +Reduction may additionally perform point lookups for provisioned entry ids and bounded branch lookups for effective model, thinking, and active-tool configuration at the operation anchor. These are indexed lookups, not extra history scans. Every scan is bounded by the open operation or the still-relevant idle queue, not by total session history or another lane's activity. + +An idle lane's remaining state is pending next-run queue items. Next-run messages can be enqueued at any time; only the acceptance of a run consumes them — compaction and navigation pass over the queue. Pending items are the `queue_enqueued` records after the lane's most recent run-kind `operation_started` whose provisioned entries do not exist and that no `queue_cancelled` retracts. Items a run captured are listed in its intent's `initialMessages`, so a captured-but-unappended item is completed by that run's recovery and is never offered to the next run. ### The reduction From those two reads, the lane's state: - **aborting** — an `abort_requested` record exists. -- **attempts used** — `step_attempt` records whose `resultEntryId` has no entry. A step is closed exactly when its provisioned result exists — a point lookup, not adjacency inference; attempts whose result landed belong to finished work. +- **attempts used** — the newest `step_attempt`, when its `resultEntryId` has no entry, is the unfinished step; its `attempt` field is the durable count, its kind and `compactionReason` select the resume path. Closure is a point lookup, not adjacency inference: a step is closed exactly when the newest attempt's provisioned result exists. Earlier attempts' unfulfilled ids belong to finished work and need no inspection. - **overflow recovery used** — a compaction `step_attempt` with reason `overflow` is newer than the newest consumed conversational message of this run (section 6, overflow guard). - **tool batch** — the newest assistant entry with tool calls, each call matched against `tool_started` records and result entries (section 6, crash-site table). The assistant stop reason is retained: a `length` batch is truncated and never executes on recovery. Persisted `terminate` values on result entries decide whether the completed batch forces another turn. - **deferred handle** — the newest own entry is a deferred assistant message with no successor. +- **newest own entry** — the last entry of the second read; the pure predicates (`needsAssistant()`, terminal failure, abort closure) read it. - **pending queue items** — `queue_enqueued` records whose provisioned entry does not exist, excluding items retracted by `queue_cancelled` and steer/follow-up items killed by this run's `abort_requested`. - **pending writes** — `write_deferred` records whose provisioned entry does not exist. - **missing initial messages** — provisioned ids from the run intent without entries. @@ -834,6 +840,9 @@ interface AgentHarnessOptions { compaction?: CompactionSettings; steeringMode?: QueueMode; followUpMode?: QueueMode; + /** Batch default; a called tool declaring executionMode "sequential" + forces sequential regardless (section 14). */ + toolExecution?: "sequential" | "parallel"; // default parallel /** automatic: operation methods drive their procedures to completion. manual: the operation's effects park at the gate; peekAction() / executeAction() / runToCompletion() drive them. Deterministic tests @@ -1034,6 +1043,8 @@ Calls on a faulted harness reject with the same `HarnessFault` instance until th `finalMessage` is the run's newest entry that projects to an assistant message; `finalEntryId` is that entry's id. `leafId` is the lane's leaf when the operation finished — the race-free anchor for branch queries (`findEntriesOnBranch({ start: leafId })`). The two differ when a deferred write was applied after the final assistant message. Full transcripts are not duplicated into results; they are in the session and were delivered as events. +**Type provenance.** Core conversation and tool types (`AgentMessage`, `AgentTool`, `AgentToolResult`, `QueueMode`, `ThinkingLevel`) come from `packages/agent/src/types.ts`. Provider types (`Model`, `Models`, `Usage`, `RetryPolicy`, stream options, deferred handles) come from `packages/ai`. Session, harness, hook, event, result, snapshot, navigation, and durable-record types are defined by the v2 implementation under `packages/agent/src/harness/`. Lowercase helpers in section 15 pseudocode without a definition (`preparation`, `runToolBatchForSingleCall`, request/option bags such as `AssistantRequest` and `FactWrite`) are constructive implementation detail, not contract. + ### Suspended operations ```ts @@ -1170,14 +1181,14 @@ Guarantees: - Events that report durable facts fire after the fact is committed; what an event announces is already queryable. - Events report final values, after hook transformation. - Payloads are JSON-serializable and secret-free; a server can proxy them verbatim. Live objects (models, tools) are referenced by name, never embedded. -- Lane-scoped events carry `lane: string` (omitted below); harness-global events such as `fault` omit it. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. +- Lane-scoped events carry `lane: string` (omitted below); harness-global events omit it — except `usage`, which is delivered harness-globally and carries the record's lane in its payload. Operation-scoped events carry `runId`; turn-scoped events carry `turnId`; recovered work carries `recovery: true`. ### Catalog ```ts // Run lifecycle { type: "run_start"; runId } -{ type: "run_resume"; runId } // resume() entered +{ type: "run_resume"; runId } // resume() entered (any operation kind) { type: "run_suspend"; runId; deferred: DeferredHandle } // lane parked { type: "run_abort"; runId; steer: AgentMessage[]; followUp: AgentMessage[] } // abort accepted; cleared payloads { type: "run_end"; runId; outcome: "completed" | "aborted" | "failed"; @@ -1368,8 +1379,9 @@ after_tool: { // Structural operations ------------------------------------------------ // Decline, adjust, or supply the summary. Runs after operation_started, -// live and on resume alike. Not re-run when the result entry exists or a -// step_attempt already durably selected generated-summary work. +// live and on resume alike. Not re-run when the result entry exists or +// any step_attempt for this work already exists (hook-written or generated +// — records cannot distinguish them, and neither needs the hook again). before_compaction: { event: { reason: "manual" | "threshold" | "overflow"; preparation: CompactionPreparation; customInstructions? }; result: { decline?: boolean; compaction?: CompactResult } | undefined; @@ -1377,12 +1389,7 @@ before_compaction: { before_navigation: { event: { targetId; preparation: NavigationPreparation }; - result: { - decline?: boolean; - summary?: { summary: string; details?; usage? }; - customInstructions?: string; - label?: string; - } | undefined; + result: { decline?: boolean; summary?: { summary: string; details?; usage? } } | undefined; } ``` @@ -1398,7 +1405,7 @@ Hooks re-run only where the work itself re-runs. Persisted outputs are never rec | `after_response` | per response | per response | per response | | `before_tool` | per call | — | not when `tool_started` exists | | `after_tool` | per executed result | — | on safe replay only | -| `before_compaction`, `before_navigation` | per operation | no | not when a result entry or a generated-summary `step_attempt` exists | +| `before_compaction`, `before_navigation` | per operation | no | not when a result entry or any `step_attempt` for this work exists | | `before_run_end` | per normal finish boundary | — | at the boundary resume reaches (may repeat); never for abort, terminal failure, or exhausted auto-compaction | ## 12. Session and SessionTree @@ -1416,7 +1423,8 @@ interface EntryBase { timestamp: number; // Unix ms, storage-assigned } -interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage } +interface MessageEntry extends EntryBase { type: "message"; message: AgentMessage; + terminate?: true } interface ModelChangeEntry extends EntryBase { type: "model_change"; provider: string; modelId: string } interface ThinkingLevelEntry extends EntryBase { type: "thinking_level_change"; thinkingLevel: string } interface ActiveToolsEntry extends EntryBase { type: "active_tools_change"; activeToolNames: string[] } @@ -1536,6 +1544,9 @@ class Session implements SessionTree { // bound to "main" query: RecordQuery & { type: K }, ): Promise[]>; findRecords(query?: RecordQuery): Promise; + /** Unfinished operation starts, newest first. limit: 2 distinguishes the + valid zero/one states from multiple-open-operation corruption. */ + findOpenOperations(lane: string, options?: { limit?: number }): Promise; /** Full chronological view: entries, records, facts, lane moves, merged by seq. Debugging and tests. */ getLog(options?: { afterSeq?: number; limit?: number }): Promise; @@ -1547,6 +1558,8 @@ interface RecordQuery { lane?: string; type?: LaneRecord["type"]; runId?: string; + /** Valid only with type "operation_started". */ + operationKind?: OperationStartedRecord["intent"]["kind"]; afterSeq?: number; order?: "oldestFirst" | "newestFirst"; limit?: number; @@ -1561,7 +1574,7 @@ The old `getStorage()` escape hatch is gone: all writes flow through `Session`, ### Contract -One session per storage instance. Storage persists and answers queries; `Session` owns validation and view binding. Storage knows nothing about operations, queues, or recovery; record payloads are opaque except for indexed columns. +One session per storage instance. Storage persists and answers queries; `Session` owns validation and view binding. Storage never executes operations, queues, or recovery. Record payloads are opaque except for indexed columns and the required open-operation recovery projection. ```ts interface SessionStorage { @@ -1588,6 +1601,7 @@ interface SessionStorage { query: RecordQuery & { type: K }, ): Promise[]>; findRecords(query?: RecordQuery): Promise; + findOpenOperations(lane: string, options?: { limit?: number }): Promise; getLog(options?): Promise; // Global facts @@ -1605,11 +1619,12 @@ Contract rules, all backends: - `Session` and the harness provision ids with `session.idGenerator`; storage enforces per-session uniqueness at append. - Every durable payload must be JSON-serializable. `Session` validates before dispatch so Memory, JSONL, and SQLite accept the same values; Memory does not retain values JSONL would reject. - Reads return immutable data. +- `findOpenOperations` is a required recovery projection: Memory maintains it with its record state, JSONL derives it while replaying the file, and SQLite answers it with indexed records. It returns unfinished starts newest first and must expose a second result so recovery can reject multiple open operations. - No conditional writes exist. Single-writer plus the lane mutation line make compare-and-set unnecessary; storage stays plain appends and pointer/fact updates. - One writer per session, enforced by the serving layer; SQLite additionally rejects a second writer itself. Per session, not per backend: one SQLite database hosts many sessions, each with its own single writer. - Any write failure faults the harness (section 4). The store is left a valid prefix. - Global-fact and lane-move history is kept, never rewritten: latest by `seq` wins. History is the cheaper implementation (insert, never update), and lane-move history is a reflog if anyone ever wants one. -- `getStats()` for format-4 sessions is the sum of `usage` records across all lanes — one rule, nothing entry-derived, no double counting by construction. Backends maintain it as a running projection updated per record commit, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. +- For format-4 sessions, the token and cost fields returned by `getStats()` are the sum of `usage` records across all lanes — one rule, no entry-derived billing, and no double counting by construction. `messageCount` counts message entries appended by this session; entries copied into a fork do not increment it. Backends maintain both as running projections, so reads and the `usage` event's totals are O(1). Format-3 sessions have no records; their usage stats stay entry-derived. The one-time v4 conversion writes one aggregate `adjustment` record (`details: { source: "v3-import" }`) summing the v3 entries' usage, so totals survive conversion. Outside the ledger's claim: the settle-to-write crash window, unreported mid-stream billing, tools that die without reporting, and extension-private LLM calls (section 1 non-goal) — though `adjustment` records let an application close even those after the fact. ### Memory @@ -1617,10 +1632,30 @@ Plain structures: entry map, record list, lane map, fact lists, one seq counter, ### JSONL +The concrete repository is `JsonlSessionRepo`. Its metadata and options extend the backend-neutral contracts: + +```ts +interface JsonlSessionMetadata extends SessionMetadata { + cwd: string; + path: string; + modifiedAt: number; // filesystem mtime used for listing order + sourceFormat: 3 | 4; + /** Present only when a v3 parent path could not yet be resolved to an id. */ + legacyParentSessionPath?: string; +} +interface JsonlSessionCreateOptions extends SessionCreateOptions { + cwd: string; + metadata?: Record; +} +interface JsonlSessionListOptions { cwd?: string; } +``` + +A v3 `parentSession` path resolves to the parent header's id when that file is available. If it is unavailable, metadata retains `legacyParentSessionPath`; first-write conversion preserves that optional header field rather than silently dropping the relationship. Format-4 code uses `parentSessionId` for repository relationships. `modifiedAt` is read from the filesystem and is not a sequenced session mutation. + One file per session: a header line, then one JSON object per line, in `seq` order. Every logical mutation is exactly one line; a line is the atomic unit. ```text -{"kind":"header", "version":4, id, createdAt, cwd, parentSessionId?} +{"kind":"header", "version":4, id, createdAt, cwd, parentSessionId?, legacyParentSessionPath?, metadata?} {"kind":"entry", "lane":"main", id, parentId, type, timestamp, ...} {"kind":"record", "lane":"main", id, runId?, type, timestamp, ...} {"kind":"lane", "lane":"slack:t1", "leafId":"e42"} // create or move @@ -1647,14 +1682,17 @@ lane_moves (session_id, seq, lane, leaf_id) -- history; getLog parity facts (session_id, seq, kind, key, value) -- name, labels; latest by seq branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) branch_tips (session_id, branch_id, tip_id) -- PRIMARY KEY (session_id, tip_id) -leases (session_id, owner, heartbeat) -- writer claim +leases (session_id, owner_id, fence, expires_at_ms) -- writer claim -- indexes records: (session_id, lane, type, seq), (session_id, lane, type, op_kind, seq) + (session_id, lane, run_id, type) branch_entries: (session_id, branch_id, entry_type, entry_seq) (session_id, entry_id) -- reverse lookup: entry → branches ``` +`leases` enforces one writer per session with expiring, fenced claims. Storage renews the claim inside every write transaction and while idle. Repository-owned cleanup releases only its matching owner and fence. + `branch_entries` and `branch_tips` are a private read cache. No interface exposes them; no other backend has them; rebuilding them from parent pointers is an explicit repair operation, never a runtime fallback. Two invariants carry the whole design: @@ -1804,7 +1842,7 @@ export interface ToolCallbacks { args?: Record; block?: { reason: string }; } | undefined>; - afterToolCall?(call, result, isError, signal): Promise; + afterToolCall?(call, args, result, isError, signal): Promise; /** Between phases 1 and 2: the durability point. The harness writes its tool_started record here. Called in source order in both modes — preparation is always sequential. */ @@ -1906,7 +1944,7 @@ The jobs, by caller: - **Lane surface** (ungated, enqueue directly): - *Operation acceptance* — validate idle, capture the pending `nextRun` items into `initialMessages`, write `operation_started`, set `state.operation`. The second of two concurrent acceptances sees the first and rejects `busy` with no write. `before_run` ran before this job, outside the line, on the prompt only. - *Queue acceptance* (`steer`, `followUp`) — validate an active, non-aborting run; write `queue_enqueued`. `nextRun` validates nothing and always accepts. - - *Queue cancellation* (`cancelQueued`) — target entry exists: `already_consumed`; not pending (abort-drained or already cancelled): `already_cleared`; else write `queue_cancelled` and remove the item from its pending set. + - *Queue cancellation* (`cancelQueued`) — no `queue_enqueued` for the id: `Err(UnknownQueueItem)`; target entry exists: `already_consumed`; not pending (abort-drained or already cancelled): `already_cleared`; else write `queue_cancelled` and remove the item from its pending set. - *Deferred-write acceptance* (lane-view writes, config setters) — run open: write `write_deferred`; structural operation open: wait for it to end, then re-enter; idle: append the entry directly. - *Abort* — write `abort_requested`, set `aborting`, drain `pendingSteer`/`pendingFollowUp` (payloads return to the abort caller and in the `run_abort` event), signal the active effect's `AbortController`. - *Resume admission* — reserve the lane's single execution slot; no write. @@ -2013,6 +2051,7 @@ Semantics that make tests deterministic: records and own entries (section 7): live commits update it; restore recomputes it. */ interface LaneState { + lane: string; leafId: string | null; operation: null | { id: string; @@ -2076,14 +2115,17 @@ async function appendIfMissing(target: ProvisionedEntry): Promise { ```ts async function resume(): Promise { if (missing.tools.length || missing.models.length) { - return Result.err(new MissingIdentities({ lane: laneName(state), ...missing, + return Result.err(new MissingIdentities({ lane: state.lane, ...missing, message: "Missing tools or models" })); } + await fx.runHook("before_resume", beforeResumeEvent(state)); // per registration id (section 11) emit({ type: "run_resume", runId: op.id, recovery: true }); + // tagResume re-tags an operation Result as a ResumeResult: Ok gains + // { operation }, Err passes through unchanged. switch (op.kind) { - case "run": return { kind: "run", ...await runProcedure() }; - case "compaction": return { kind: "compaction", ...await compactionProcedure() }; - case "navigation": return { kind: "navigation", ...await navigationProcedure() }; + case "run": return tagResume("run", await runProcedure()); + case "compaction": return tagResume("compaction", await compactionProcedure()); + case "navigation": return tagResume("navigation", await navigationProcedure()); } } @@ -2126,6 +2168,9 @@ async function handleRunSignal(e: unknown): Promise { } ``` + +**Fixed-point self-check.** When `resume()` completes, parks, or closes its operation, the harness recomputes the section 7 reduction from storage and compares it to the live `LaneState`. A mismatch is corruption and faults the harness — writer/reducer drift is caught the moment it happens instead of one crash later. The check is cheap (the same two bounded reads restore performs) and runs in production, not only under test. + ### The loop ```ts @@ -2212,7 +2257,7 @@ async function handleRunFailed(error: OperationError): Promise { ### Steps -A failed attempt appends nothing; only a deferred handle, a terminal message, or the final give-up error enters the tree (section 6, retry trace). +A failed attempt appends nothing. Besides the successful response, only a deferred handle, a terminal message, or the final give-up error enters the tree (section 6, retry trace). ```ts async function assistantStep(): Promise { @@ -2220,9 +2265,10 @@ async function assistantStep(): Promise { if (op.aborting) throw new Aborted(); const attempt = (op.step?.kind === "assistant" ? op.step.attempts : 0) + 1; if (attempt > retry.maxAttempts) { + const error = retriesExhausted(); // The give-up entry fulfills the last attempt's provisioned id. - await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state)); - throw new RunFailed(retriesExhausted()); + await fx.appendEntry(giveUpAssistantEntry(lastAttemptResultId(op), state, error)); + throw new RunFailed(error); } const options = await fx.runHook("before_request", @@ -2255,7 +2301,7 @@ async function assistantStep(): Promise { `isRecoverableOverflow(final, state)` is `isContextOverflow(final)` — overflow-pattern errors and silent overflow — or `isRecoverableLength(final, desiredMaxOutput(state))` from section 6, where `desiredMaxOutput(state)` is the caller-supplied `maxTokens` when set, else the lane model's `maxTokens`. The check runs before the retryable-error branch: an overflow-form error compacts instead of retrying the same oversized request. -`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. +`summaryStep(step, reason, resultEntryId)` has the same shape: `step_attempt` before each attempt (`compactionReason` for compaction steps) carrying the step's single result id, `before_request`, one or two non-deferred requests — each followed by its `usage` record bound to that id — durable cap. It returns the summary value; the caller appends the result entry under that id. A hook-supplied summary makes no request and no request record; if it carries usage the hook measured itself, the appending procedure writes a `hook` usage record beside the entry. For reason `overflow` the appending procedure also writes the compaction `step_attempt`, so the once-per-input guard counts the recovery (section 6). ### Deferred redemption @@ -2267,7 +2313,10 @@ async function redeemDeferred(): Promise { await fx.appendRecord(usageRecord("deferred_fetch", op.id, resultEntryId, 1, final)); } if (op.aborting) throw new Aborted(); - if (final.stopReason === "deferred") throw new Park(op.deferred!); // pending; no other write + if (final.stopReason === "deferred") { + requireSameHandle(final.deferred, op.deferred!); // mismatch is a defect (section 16) + throw new Park(op.deferred!); // pending; no other write + } if (final.stopReason === "aborted") throw new Aborted(); await fx.appendEntry(assistantEntry(resultEntryId, final)); // ready or terminal @@ -2278,8 +2327,6 @@ async function redeemDeferred(): Promise { One fetch per `resume()`. Pending re-parks without a write. A terminal answer — returned or converted from a rejected fetch — lands as the error entry and fails the run through the normal drain path, which still honors input accepted before the failure (section 6). -**Fixed-point self-check.** When `resume()` completes, parks, or closes its operation, the harness recomputes the section 7 reduction from storage and compares it to the live `LaneState`. A mismatch is corruption and faults the harness — writer/reducer drift is caught the moment it happens instead of one crash later. The check is cheap (the same two bounded reads restore performs) and runs in production, not only under test. - ### Tools The live path is section 14 `executeToolBatch`; the durability callbacks route through `fx`, so the gate and the traces see every write in order: @@ -2304,8 +2351,8 @@ async function runToolBatch(assistant: AssistantMessage): Promise { replay: declaredReplay(call), })); }, - afterToolCall: (call, result, isError) => - fx.runHook("after_tool", { toolCallId: call.id, toolName: call.name, ...result, isError }), + afterToolCall: (call, args, result, isError) => + fx.runHook("after_tool", { toolCallId: call.id, toolName: call.name, args, ...result, isError }), onToolResult: async (message, terminate) => { // Blocked/invalid calls have no tool_started and no provisioned id; // their error result entry gets a fresh id (section 5). @@ -2339,7 +2386,8 @@ async function reconcileToolBatch(batch: ToolBatchState): Promise { tool: toolByName(call.started.toolName), args: call.started.effectiveArgs }; // persisted, not re-derived const executed = await fx.executeTool(prepared); - const finalized = await finalizeToolCall(prepared, executed, { afterToolCall }, abortSignal); + const finalized = await finalizeToolCall(prepared, executed, + { afterToolCall }, toolContext, abortSignal); // the fx-wired hook callback (runToolBatch) if (finalized.result.usage) { await fx.appendRecord(toolUsageRecord(op.id, call.started.resultEntryId, call.toolCall.id, finalized.result.usage)); // the replay's own record @@ -2389,9 +2437,13 @@ async function compactionProcedure(): Promise { let result: CompactResult | undefined; if (!op.step) { // no attempt yet: the decision hook may still run const hook = await fx.runHook("before_compaction", - { reason: "manual", preparation, customInstructions: op.intent.customInstructions }); + { reason: "manual", preparation: preparation(state), + customInstructions: op.intent.customInstructions }); if (hook?.decline) return await finishStructural("declined"); result = hook?.compaction; + if (result?.usage) { + await fx.appendRecord(hookUsageRecord(op.id, op.intent.resultEntryId, result.usage)); + } } result ??= await summaryStep("compaction", "manual", op.intent.resultEntryId); await appendIfMissing(compactionEntry(op.intent.resultEntryId, result)); @@ -2408,14 +2460,25 @@ async function compactionProcedure(): Promise { RunFailed: without compaction the request cannot fit (section 6). */ async function autoCompact(reason: "threshold" | "overflow"): Promise { const resultEntryId = op.step?.kind === "compaction" ? op.step.resultEntryId : newId(); - if (!op.step) { - const hook = await fx.runHook("before_compaction", - { reason, preparation: preparation(state) }); + if (op.step?.kind !== "compaction") { // no durable compaction decision yet; on the overflow + // path op.step is the abandoned assistant step + const prep = preparation(state); + if (nothingToCompact(prep)) { + if (reason === "overflow") throw new RunFailed(truncationError()); + return; + } + const hook = await fx.runHook("before_compaction", { reason, preparation: prep }); if (hook?.decline) { if (reason === "overflow") throw new RunFailed(truncationError()); return; } if (hook?.compaction) { + if (reason === "overflow") { // the once-per-input guard counts this attempt + await fx.appendRecord(stepAttempt(op.id, "compaction", 1, resultEntryId, reason)); + } + if (hook.compaction.usage) { + await fx.appendRecord(hookUsageRecord(op.id, resultEntryId, hook.compaction.usage)); + } await appendIfMissing(compactionEntry(resultEntryId, hook.compaction)); return; } @@ -2433,10 +2496,14 @@ async function navigationProcedure(): Promise { if (op.intent.summarize && !op.targets.summary) { if (!moved && !op.step) { // decision hook: once, pre-move const hook = await fx.runHook("before_navigation", - { targetId: op.intent.targetId, preparation }); // preparation derives from + { targetId: op.intent.targetId, + preparation: preparation(state) }); // preparation derives from // intent.sourceLeafId — valid pre- and post-move if (hook?.decline) return await finishStructural("declined"); summary = hook?.summary; + if (summary?.usage) { + await fx.appendRecord(hookUsageRecord(op.id, op.intent.summaryEntryId!, summary.usage)); + } } summary ??= await summaryStep("branch_summary", undefined, op.intent.summaryEntryId!); // regenerates after a post-move crash @@ -2487,6 +2554,7 @@ Hook-to-block wiring, in one table: | `before_tool` | `ToolCallbacks.beforeToolCall` (phase 1) | | `after_tool` | `ToolCallbacks.afterToolCall` (phase 3) | | `before_run_end` | `driverLoop` finish boundary; result committed via `fx.commitRunEndFollowUp` | +| `before_resume` | `resume()` dispatch, before any effect | | — (record/entry writes) | `ToolCallbacks.onToolStart` / `onToolResult` via `fx` | Notes: @@ -2531,6 +2599,30 @@ interface AssistantMessage { deferred?: DeferredHandle; // present iff stopReason === "deferred" } +// Authenticated HTTP request plumbing shared by stream, image, and deferred +// provider operations. Generation and streaming-transport controls are not +// part of this interface. +interface ProviderRequestOptions { + signal?: AbortSignal; + apiKey?: string; + fetch?: FetchFunction; + env?: ProviderEnv; + onPayload?: (payload: unknown, model: Model) => + unknown | undefined | Promise; + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + headers?: ProviderHeaders; + timeoutMs?: number; + maxRetries?: number; + maxRetryDelayMs?: number; +} + +interface DeferredFetchOptions extends ProviderRequestOptions { + /** Maximum provider long-poll duration. Omitted or zero checks once. */ + wait?: number; +} + +type DeferredCancelOptions = ProviderRequestOptions; + // Redemption lives on the provider. The two methods are optional: their // presence is the capability signal. A provider without them never returns // stopReason "deferred" and ignores the deferred request option. @@ -2546,28 +2638,30 @@ export interface ProviderStreams { `wait` expires; wait: 0 checks once) - terminal: stopReason "error" (expired, unknown, consumed) */ fetchDeferred?(model: Model, handle: DeferredHandle, - options?: { wait?: number; signal?: AbortSignal }): AssistantMessageEventStream; + options?: DeferredFetchOptions): AssistantMessageEventStream; /** Best effort; providers without cancellation omit it. */ - cancelDeferred?(model: Model, handle: DeferredHandle): Promise; + cancelDeferred?(model: Model, handle: DeferredHandle, + options?: DeferredCancelOptions): Promise; } ``` The harness never talks to a provider object directly; it uses the same authenticated dispatch surface as ordinary requests: ```ts -type ModelsDeferredOptions = StreamOptions & ModelsStreamTransforms; +type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms; +type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms; interface Models { // existing methods fetchDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions): Promise; + options?: ModelsDeferredFetchOptions): Promise; cancelDeferred(model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions): Promise; + options?: ModelsDeferredCancelOptions): Promise; } ``` -`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge); `ModelsDeferredOptions` carries the normal `AbortSignal`, transport, response callbacks, and model transforms. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. +`Models.fetchDeferred` and `Models.cancelDeferred` delegate to the provider methods with normal model resolution and authentication (credential store, expiring tokens, header merge). Their options carry the normal HTTP request settings, lifecycle callbacks, and model transforms; fetch options additionally carry the provider long-poll duration. A provider that returns `stopReason: "deferred"` must implement fetch; cancellation is best effort. A terminal fetch answer is final for the run: the harness appends the error message and fails the operation (section 6). It never starts an automatic replacement request. The executor converts a rejected fetch promise into the same `stopReason: "error"` message form, so expected provider and authentication failures stay in-band. On a returned pending message the harness requires the complete handle to equal the persisted handle: a provider cannot replace durable handle data without a write, so a mismatch is a defect. @@ -2599,7 +2693,9 @@ repo.create({ id?, parentSessionId? }): Promise; ## 18. Telemetry -Telemetry uses explicit context propagation. Core code does not use `AsyncLocalStorage`, global current-span state, or runtime-specific context APIs. This section defines the harness telemetry mechanism; `packages/agent/docs/observability.md` provides background only. +Telemetry uses explicit context propagation. Core code does not use `AsyncLocalStorage`, global current-span state, or runtime-specific context APIs: pi runs in Node, Bun, browsers, and workers, so no runtime's ambient-context mechanism can be the core abstraction, and explicit arguments are the only portable one. This section is the complete telemetry design; no other document defines any of it. + +Pi ships no exporter and depends on no telemetry vendor. The application supplies the `ExecutionContext` below; an **adapter** is such an implementation that bridges spans into OTel, Sentry, logs, or metrics. The contract passes live span objects, not span/trace ids — an adapter that needs ids (every OTel-shaped backend does) allocates and correlates them internally, so core never carries id plumbing. An adapter may use `AsyncLocalStorage` inside its own implementation on runtimes that have it; core will never require it. ### Context contract @@ -2686,7 +2782,6 @@ pi.harness.run runId, lane, recovery pi.harness.compaction manual operation pi.harness.navigation -pi.harness.resume ``` The harness owns the operation and turn spans. The `fx` implementation owns checkpoint, step, request, tool, hook, and append spans: `fx.streamAssistant` creates the retryable step span and its request children, and each tool step gets its own tool span. This split follows ownership of the corresponding work. @@ -2735,7 +2830,7 @@ The in-memory backend is the reference. The parity suite runs the same setups ag ### Tier B — writer conformance -Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, manual compaction, navigation (move-first), deferred suspension and every fetch outcome. This tier catches the critical regression class: an effect starting before its intent record. +Tier A assumes live execution writes the correct prefix; Tier B verifies it. Run the public harness against an instrumented `Session` recording every entry (`E`), record (`R`), lane move (`L`), fact (`G`), and hook (`H`). Assert exact order against the section 6 traces: one-tool run, retry, terminal failure, steering during a tool, queue cancellation, finish-boundary orders, deferred write mid-turn, abort during a tool, auto-compaction, context overflow (discard, guard, hook-supplied), manual compaction, navigation (move-first), deferred suspension and every fetch outcome. This tier catches the critical regression class: an effect starting before its intent record. Tier B also asserts the append-only-context invariant (section 4) executably: within a run, every faux-provider request's message list extends the previous request's as an exact prefix — except across a compaction entry, the one sanctioned invalidation. This turns the KV-cache discipline from prose into a failing test whenever a write path inserts before the tail. @@ -2762,8 +2857,7 @@ Crash simulation is `close()` at a chosen boundary, then reopening the same back Gate invariants, asserted across Tier C: -- after every `resume()` outcome, the recomputed reduction equals live `LaneState` (the section 15 fixed-point self-check fired and passed); - +- After every `resume()` outcome, the recomputed reduction equals live `LaneState` (the section 15 fixed-point self-check fired and passed). - `peekAction()` has no side effect and is stable until `executeAction()`. - `executeAction()` releases exactly the peeked action, never a later one. - Stopping before an action leaves exactly the preceding durable prefix. @@ -2776,48 +2870,288 @@ Gate invariants, asserted across Tier C: - The existing `agent-loop` and `agent` suites pass unchanged — the section 14 compatibility criterion. - Event ordering per section 10, including `message_end` after commit. - Hooks: registration-id `resumeData` round trips, duplicate-id rejection, aggregation order, fail-closed `before_tool`. -- Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` equals the ledger sum and the `usage` event's totals after every commit; forks start at zero; v3 conversion preserves totals through the aggregate import adjustment. +- Ledger completeness and the match invariant: every provider request leaves exactly one `usage` record per physical request (split-turn: two per attempt; a pending deferred fetch that reports no usage writes none); failed compaction series and discarded overflow responses lose no recorded cost; each usage-bearing entry's snapshot equals the newest non-adjustment record(s) bound to its id; a replayed tool records both executions; adjustments never alter entries and sum into read-time effective cost; `getStats()` equals the ledger sum and the `usage` event's totals after every commit; forks start at zero; v3 conversion preserves totals through the aggregate import adjustment. - Overflow classification against the reported provider shapes: prompt 268,009 of a 272,000 window and 81,217 of 84,500 (recoverable), non-zero reasoning-only output, cache-write-heavy usage, a Codex-style provider that rejects `max_output_tokens`, a genuine 1,024-token cap fully used (not recoverable), and `length → length` stopping after exactly one recovery per conversational input. - v3 fixtures: labels, session info, and `leaf` entries mid-chain and at end of file, old `firstKeptEntryId` compactions — all open as one normalized idle `main` lane. -## 21. Implementation sequence +## 21. Implementation status and work packages + +Implementation lives directly in `packages/agent/src/harness/`, with v4 session tests in `packages/agent/test/harness/session/`. The v4 session and SQLite backend are the default package interfaces; there is no experimental package surface. Retained compaction, message projection, resource, tool, environment, and utility modules stay under `src/harness/` and are adapted in place. + +### Landed foundation + +- The v4 `Session`, in-memory storage, backend-neutral conformance suite, and test helpers are the default agent package API. +- The v4 SQLite repository, branch cache, leases, forks, and conformance coverage are the default SQLite package API. +- The old harness/session runtime and experimental export surface have been removed. +- `AgentHarness` is type-complete but not behavior-complete. Execution-bearing methods reject with `HarnessNotImplemented`; some read/configuration/watch/manual-drive methods still expose local scaffold behavior. F0 owns the audit and hardening of every public method before runtime work begins. +- Compaction preparation, context projection, and branch summarization use v4 `Entry` queries and no longer depend on the legacy `SessionTreeEntry` model. Reusable compaction tests and dedicated context tests cover this interim behavior. +- `Session.getBranch()` is not part of v4 and must not be reintroduced. Branch callers use `findEntriesOnBranch()` with an explicit start and deliberate ordering semantics; callers may use the documented `newestFirst` default. +- The pi-ai deferred request types, `Models.fetchDeferred()` / `cancelDeferred()` dispatch, and faux-provider pending/ready/terminal/cancellation support are already implemented and tested. Only harness integration remains. + +### Scope boundary + +`packages/coding-agent/**` remains untouched. This plan implements `packages/agent` and `packages/storage/sqlite-node` only. The v3 requirement is an input-format requirement for the new JSONL repository, not a coding-agent migration. No work package may modify coding-agent source, tests, RPC, UI, or package metadata. -Implementation lives in `packages/agent/src/harness/experimental/`, tests in `packages/agent/test/harness/experimental/`. Nothing outside `experimental/` is modified in place; when the experimental implementation is complete and green, **everything currently under `src/harness/` outside `experimental/` is removed** and the experimental code replaces it wholesale. +### Package rules -Keep each stage passing before starting the next. +Each package below is intended to be one focused PR or commit. Its dependencies must already be merged. A package is complete only when: -1. v4 `Session` and in-memory storage: entries, records, lanes, facts, shared `seq`. Backend-neutral parity suite. -2. JSONL v4 with the v3 read path; greenfield SQLite with the branch cache. Parity suite against all three. -3. Section 7 reduction and validity checks. Tier A before any live procedure exists. -4. Split `agent-loop.ts` into the section 14 blocks; existing `agent-loop`/`agent` tests pass unchanged. -5. `Effects`, the lane mutation line, the conditional commits, and the gate. Automatic/manual equivalence for a no-tool run. -6. The run procedure: acceptance with capture, checkpoints, steps and retries, overflow recovery, queues, deferred writes, terminal failure, conditional finish, abort. Tier B traces as they land. -7. Tool batches through the section 14 callbacks, `terminate` persistence, replay and reconciliation. The full tool crash matrix in Tier A and C. -8. Deferred provider requests through `Models`; faux-provider support for pending, ready, terminal, and cancellation outcomes. -9. Manual and auto compaction; navigation with the move-first commit. -10. Events, hooks, snapshots, telemetry context. Tier C race-catalog completion. -11. Adapt coding-agent to the new result and session APIs; run its non-e2e suites on the faux provider. -12. `npm run check` clean. +1. its listed behavior is implemented without fake success paths; +2. its focused tests pass, including every test file it creates or changes; +3. existing compatibility tests named by the package still pass unchanged; +4. `npm run check` passes; and +5. its PR reports any contract correction discovered during implementation. + +Incomplete public operations reject with `HarnessNotImplemented`; do not make a scaffold method return a plausible but non-durable result. Do not restore deleted APIs or compatibility shims. The only compatibility target is coding-agent v3 JSONL input. + +Packages in the same file-ownership lane merge serially. In particular, only one package at a time may own `agent-loop.ts`, and only one package at a time may own `agent-harness.ts`. Storage, reducer, loop-splitting, and primitive packages may proceed in parallel only when their primary files below do not overlap. + +To avoid every parallel PR conflicting in this file, package authors do not edit the checkboxes below. The designated merge/status owner marks a package done immediately after merge in a small serialized documentation update. Until that update lands, fresh sessions verify status with `git log` and the package's acceptance tests. + +### Fresh-session pickup protocol + +1. Read this document, then the package-specific files from section 22. +2. Check the package list, `git log`, and focused tests; do not infer status from the scaffold alone. +3. Choose an unfinished package whose dependencies are done and whose ownership lane has no active owner. +4. Keep changes inside the package's primary files unless a discovered contract error requires a documented correction. +5. Run focused tests while iterating, then `npm run check` before handoff. +6. Report completion to the merge/status owner so the checkbox is updated immediately after merge. + +For one serial worker, the recommended next package is **R0**, because it unblocks both reducer and JSONL work; **F0** follows. For a team, the dependency-ready roots are **F0, R0, QA1, I0, I1, and I2**. After I0 lands, L1 is also ready. Do not start J0 or R1 before R0. + +### Track F — scaffold truth and public ownership + +- [ ] **F0 — harden the scaffold.** Dependencies: none. + - Primary files: `packages/agent/src/harness/agent-harness.ts`, `packages/agent/test/harness/agent-harness-scaffold.test.ts`. + - Inventory every public method. Preserve only behavior that is genuinely correct without an operation runtime, such as immutable harness-global configuration copies and direct leaf reads. Make every other placeholder reject with `HarnessNotImplemented` instead of returning empty snapshots, idle state, or no-op drive/wait success. + - Before R3, `AgentHarness.create()` may open only a record-free session. It rejects any session containing records rather than reporting a false empty suspended list. + - Acceptance: a table-driven scaffold test covers every public method and proves no unfinished method reports plausible success. + +### Public method ownership + +This table is exhaustive. A package does not remove `HarnessNotImplemented` from a method until it owns the listed semantics and tests. + +| public surface | owning package | +|---|---| +| scaffold-safe `name`, `getLeafId`, record-free create, runtime settings | F0 | +| `AgentHarness.create()` restore and `suspended` inventory | R3 | +| `lane`, `createLane`, `lanes`, lane facades, lane-bound session reads | H0 | +| resources, stream/retry/compaction settings, queue modes | F0 | +| tool registry plus persisted active-tool selection | H4 | +| `prompt`, `skill`, `promptFromTemplate` | H1 | +| run `resume`, retries, terminal failure | H2 | +| `steer`, `followUp`, `nextRun`, `cancelQueued` | H3 | +| persisted model/thinking/active-tools, lane-view writes, `recordUsage` | H4 | +| `abort`, `waitForIdle`, `runWhenIdle`, close settlement | H5 | +| live tools and tool events | H6 | +| tool recovery through `resume` | H7 | +| deferred-handle `resume` and cancellation | H8 | +| `compact` and compaction resume | C1–C3 | +| `navigateTree` and navigation resume | N1 | +| `peekAction`, `executeAction`, `runToCompletion` primitives/integration | I5/H0 | +| hooks/events registration primitives and harness wiring | I1/I2/H0 | +| `watch`, `watchSession`, complete snapshots | O1 | + +### Track QA — promotion test audit + +These packages merge QA1 → QA2 → QA3. QA packages own the audit document and existing memory/SQLite/context tests; JSONL packages own new JSONL tests. + +- [ ] **QA1 — inventory removed tests.** Dependencies: none. + - Primary file: `packages/agent/docs/harness-v2-test-matrix.md` only. + - Map every test removed by the promotion commit to one of: covered by v4 conformance, ported under a new API, intentionally inapplicable because the API was deleted, or uncovered with a named follow-up package. + - Acceptance: no removed case is unexplained; no production or test code changes in this package. +- [ ] **QA2 — close storage and query gaps.** Dependencies: QA1, R0. + - Primary files: focused tests under `packages/agent/test/harness/session/` and existing SQLite branch/query tests. + - Port only uncovered v4 bounded-query validation/corruption, fork, immutable-read, lane, record-query, and recovery-query behavior. Do not duplicate backend conformance cases. + - Acceptance: every storage/query gap from the matrix is covered or reassigned to J1–J5 with a precise reason. +- [ ] **QA3 — close context and configuration gaps.** Dependencies: QA2, F0. + - Primary files: `packages/agent/test/harness/session/context.test.ts` and focused scaffold/configuration tests. + - Port uncovered context transforms, projectors, compaction boundaries, deferred-message omission, and configuration-state derivation. Do not restore tests of deleted entry types or APIs. + - Acceptance: every remaining matrix row is covered or explicitly inapplicable. + +### Track R — recovery query, reducer, and restore + +These packages merge R0 → R1 → R2 → R3. R1 and R2 add a reducer module instead of growing `agent-harness.ts`. R3 is the first package in this track that owns `agent-harness.ts` and therefore runs after F0. + +- [ ] **R0 — recovery-query contract.** Dependencies: none. + - Primary files: `packages/agent/src/harness/session/types.ts`, `session.ts`, `memory.ts`, SQLite record storage/repository files, backend conformance, and focused recovery-query tests. + - Add `RecordQuery.operationKind` and `findOpenOperations(lane, { limit })` exactly as specified in sections 7, 12, and 13. Memory maintains the projection, JSONL will derive it during replay, and SQLite answers it with indexed records. + - Prove that zero/one open operations are distinguishable from multiple-open-operation corruption, and that the latest run-kind start is an indexed query. Add the SQLite `(session_id, lane, run_id, type)` index. + - Acceptance: memory and SQLite have identical query behavior, invalid query combinations reject, and no restore algorithm needs a full historical scan. +- [ ] **R1 — pure record-log validity.** Dependencies: R0. + - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. + - Validate the section 5 corruption rules from discovered open starts, bounded records, and point-looked-up entries, with no writes or effects. + - Acceptance: one focused rejection test per validity bullet, plus valid prefixes at every section 6 crash point. +- [ ] **R2 — pure lane-state reduction.** Dependencies: R1. + - Primary files: `packages/agent/src/harness/reducer.ts`, `packages/agent/test/harness/reducer.test.ts`. + - Derive `LaneState`, pending queues/writes, attempts, tool batches, deferred handles, structural targets, terminal-failure state, idle next-run state, and effective configuration from the section 7 query inputs. + - Reduction exclusively owns state derivation; later recovery packages consume this state and do not re-reduce tool or operation records. + - Acceptance: table-driven tests cover idle and every suspended state; reduction is deterministic and performs no writes. +- [ ] **R3 — harness restore inventory.** Dependencies: F0, R2. + - Primary files: `packages/agent/src/harness/agent-harness.ts`, reducer integration helpers, and restore tests. + - Wire `AgentHarness.create()` to use indexed open-operation discovery, bounded idle/open scans, explicit provisioned-id point lookups, and bounded configuration lookups. Return accurate `SuspendedOperation[]` without starting effects. + - Acceptance: idle and multi-lane restore write nothing, multiple open operations reject as corruption, suspended metadata is complete, and one lane never scans another lane's traffic. `resume()` may still reject as unimplemented. + +### Track J — JSONL storage + +**In progress and reserved: @davidbrai.** The work began before this plan was split into J0–J5. Before merge, the track owner must include or rebase onto R0's recovery-query contract and report which J packages are complete. Other agents must not pick a J package while this ownership marker remains. + +These packages own `packages/agent/src/harness/session/jsonl/**`, the concrete `JsonlSessionRepo` export, and `packages/agent/test/harness/session/jsonl*.test.ts`. They merge J0 → J1 → J2 → J3 → J4 → J5 and may proceed in parallel with tracks L and I after R0. + +- [ ] **J0 — JSONL metadata and codec contracts.** Dependencies: R0. + - Primary files: JSONL type/codec modules and focused codec tests; no public repository export yet. + - Implement the `JsonlSessionMetadata`, create/list options, format-4 header, line discriminants, `modifiedAt`, metadata, and parent-id/legacy-parent-path rules from section 13. + - Acceptance: type and codec round trips cover every header field and line kind; no filesystem lifecycle yet. +- [ ] **J1 — format-4 per-session storage.** Dependencies: J0. + - Implement one-session replay/write support for entries, records, lanes, facts, statistics, branch queries, operation-kind queries, and open-operation projection. + - Keep it internal; do not export a partially implemented repository. + - Acceptance: focused round-trip tests cover every mutation, shared `seq`, query bounds, immutable reads, and JSON validation. +- [ ] **J2 — format-4 repository lifecycle and forks.** Dependencies: J1. + - Add create/open/list/delete, one writer queue per session, metadata ordering/filtering, branch/tree forks, and the concrete public `JsonlSessionRepo` export. + - Acceptance: the complete backend-neutral conformance suite passes against JSONL, including concurrent lane writes and forks. +- [ ] **J3 — format-4 crash and corruption behavior.** Dependencies: J2. + - Add torn-tail truncation, malformed-interior rejection, missing-reference rejection, and lifecycle/concurrency edge cases. + - Acceptance: acknowledged writes survive reopen and malformed non-tail data is never silently repaired. +- [ ] **J4 — read-only v3 normalization.** Dependencies: J3. + - Decode supported coding-agent v3 files into the normalized v4 logical tree: custom messages, labels, session info, leaf resolution, discarded-entry reparenting, old compactions, timestamps, parent mapping, and idle `main`. + - A read-only open must not modify the physical file. No coding-agent source or test is changed. + - Acceptance: fixture tests cover every normalization rule in section 12 and malformed v3 input. +- [ ] **J5 — first-write v3 conversion.** Dependencies: J4. + - Rewrite through a temporary format-4 file on the first mutation, preserve metadata/facts/tree and resolved or legacy parent linkage, and add the aggregate v3 usage adjustment. + - Acceptance: crash-safe conversion tests cover failure before rename, successful reopen, statistics preservation, unresolved legacy parent paths, and no second conversion. + +### Track I — primitives + +I0, I1, and I2 may proceed independently. I3 → I4 → I5 is serial and begins after R2 fixes the `LaneState` shape. These packages use separate modules with focused unit tests; I5 remains primitive-only and does not edit `agent-harness.ts`. + +- [ ] **I0 — telemetry contracts and no-op context.** Dependencies: none. + - Primary files: `packages/agent/src/harness/telemetry.ts` and focused telemetry tests. Do not edit `agent-harness.ts`; H0 replaces its temporary local type declarations after convergence. + - Define the canonical `ExecutionContext`, spans, attributes, and no-op implementation required by section 14 compatibility wrappers. + - Acceptance: no-op methods never throw, allocate effects, or retain content. Runtime span insertion remains O2. +- [ ] **I1 — hook registry and runner.** Dependencies: none. + - Primary files: `packages/agent/src/harness/hooks.ts`, `packages/agent/test/harness/hooks.test.ts`. + - Implement typed registration, stable-id validation, ordered aggregation, error isolation, fail-closed `before_tool`, and per-id resume data handling. + - Acceptance: focused tests cover every section 11 aggregation and failure rule; no operation wiring yet. +- [ ] **I2 — passive events and watch buffering.** Dependencies: none. + - Primary files: `packages/agent/src/harness/events.ts`, `packages/agent/test/harness/events.test.ts`. + - Implement passive listener isolation and the snapshot/start/unsubscribe buffer primitive used by lane and session watchers. + - Acceptance: no snapshot/event gap, ordered one-time flush, independent watchers, and `handler_error` recursion safety; no operation wiring yet. +- [ ] **I3 — lane mutation line.** Dependencies: R2. + - Primary files: `packages/agent/src/harness/lane-runtime.ts`, focused mutation-line tests. + - Implement the per-lane FIFO and state-update discipline with test-only jobs for every conditional history in section 15. + - Acceptance: jobs never interleave, rejected jobs do not poison the queue, and no external effect runs inside a job. +- [ ] **I4 — automatic `Effects` implementation.** Dependencies: I0, I1, I3, L3. + - Primary files: `packages/agent/src/harness/effects.ts`, focused effects tests. + - Implement durable writes, conditional commits, provider/tool/hook adapters, sleep, fault propagation, and live-state updates behind the complete `Effects` interface. + - Acceptance: every external effect and durable write crosses `Effects`, and a failed write faults the whole harness. +- [ ] **I5 — manual gate primitive.** Dependencies: I4. + - Primary files: `packages/agent/src/harness/gated-effects.ts`, focused gate tests. + - Implement `GatedEffects` action descriptions, stable peek, exactly-one release, run-through, and parked rejection without wiring public lane controls yet. + - Acceptance: zero effects while parked and durable-prefix close simulations at the primitive boundary. + +### Track L — agent-loop building blocks + +These packages all own `packages/agent/src/agent-loop.ts` and therefore merge strictly L1 → L2 → L3. Existing `agent-loop` and `agent` tests pass unchanged after each package. + +- [ ] **L1 — extract assistant streaming.** Dependencies: I0. + - Add `streamAssistant()` and `StreamAssistantConfig`, including explicit telemetry context; route the compatibility loop's request path through it without changing events or results. + - Acceptance: focused stream tests plus unchanged existing loop tests. +- [ ] **L2 — extract tool-call phases.** Dependencies: L1. + - Add `prepareToolCall()`, `executeToolCall()`, `finalizeToolCall()`, result helpers, replay declaration, explicit telemetry contexts, and durability callbacks without changing batch behavior. + - Acceptance: phase tests cover validation, blocking, abort, callback failure, updates, and patches. +- [ ] **L3 — compose tool batches and compatibility wrappers.** Dependencies: L2. + - Add `executeToolBatch()` with sequential/parallel source ordering, truncation, abort, and `terminate` rules; make every legacy loop export a thin composition using the no-op context. + - Acceptance: source-order and parallelism tests plus unchanged `agent-loop` and `agent` suites. + +### Track H — harness integration and run execution + +H0 converges restore and primitives into `agent-harness.ts`. H0–H8 then merge strictly in order. Each package adds its Tier A recovery cases, Tier B exact trace, relevant events/hooks, and Tier C interleavings rather than deferring testing to the end. + +- [ ] **H0 — lane facades and primitive integration.** Dependencies: R3, I2, I5. + - Wire durable lane lookup/creation/inventory, equivalent name-bound facades, canonical hook/event/telemetry types, public manual-drive controls, and ownership/close plumbing. + - Acceptance: repeated facades are equivalent, lanes remain isolated, public drive controls match gate actions, and no placeholder operation is accidentally enabled. +- [ ] **H1 — one successful no-tool run.** Dependencies: H0, L3, I1. + - Implement `prompt`, skill/template expansion, run acceptance, capture of already-pending next-run items, initial appends, one assistant step, usage record, message commit, conditional finish, result, and basic run/turn/message events/hooks. + - H3 later owns public next-run enqueue/cancel/race behavior; H1 owns capture into `operation_started.initialMessages`. + - Acceptance: automatic/manual durable logs are identical; closing after every released action restores the expected suspended prefix. +- [ ] **H2 — retry, run resume, and terminal failure.** Dependencies: H1. + - Add durable attempt counts, retry policy/backoff/events, unfinished-assistant resume, give-up error entries, terminal-failure drain, and fixed-point checks for these states. + - Acceptance: retry caps survive reopen; failed attempts record usage but no message; half-completed recovery is idempotent. +- [ ] **H3 — queues and checkpoints.** Dependencies: H2. + - Add next-run/steer/follow-up acceptance and modes, cancellation, checkpoint consumption, queue events, and finish-boundary conditionals. Consume the queue state produced exclusively by R2. + - Acceptance: both orders of race rows 2, 5, 7, and 12; provider context grows only at the tail. +- [ ] **H4 — deferred writes, persisted configuration, and adjustments.** Dependencies: H3. + - Add deferred lane-view tree/configuration writes, direct idle writes, model/thinking/active-tool persistence and lookup, `recordUsage`, pending-write snapshots/events, and finish conditionals. + - Acceptance: both orders of race rows 3 and 9; accepted writes survive crashes and abort markers; adjustments affect ledger totals but never entries. +- [ ] **H5 — abort, wait, run-when-idle, and close.** Dependencies: H4. + - Add durable abort acceptance, queue draining, pending-write application, synthetic closure messages/results, suspended abort, idle waiters/callbacks, and process-local close settlement. + - Acceptance: both orders of race rows 4, 6, 8, and 10 and crash/reopen after every abort action. +- [ ] **H6 — live durable tool batches.** Dependencies: H5. + - Wire section 14 tool callbacks through `Effects`; write `tool_started` before execution, persist finalized results and `terminate`, report usage, and emit tool events. + - Acceptance: exact one-tool and parallel-batch traces; no blocked/invalid tool writes an intent; source-order finalization is stable. +- [ ] **H7 — tool recovery.** Dependencies: H6. + - Consume R2's X1–X5 reduced state and reconcile it; replay only when persisted and current declarations are safe, preserve ordinals, and handle truncated batches without execution. Do not duplicate reducer logic. + - Acceptance: complete tool crash matrix, changed replay declarations, parallel-prefix crashes, and idempotent second recovery. +- [ ] **H8 — deferred provider redemption.** Dependencies: H7. + - Integrate the already-landed pi-ai deferred APIs: suspend, pending re-park, ready continuation, terminal/rejected fetch failure, handle mismatch, and best-effort cancellation. + - Acceptance: one fetch per resume; pending writes nothing except reported usage; terminal errors never start replacement requests. + +### Track C/N — structural operations + +These packages also own `agent-harness.ts` and merge after H8, in order C1 → C2 → C3 → N1. + +- [ ] **C1 — manual compaction operation.** Dependencies: H8. + - Add acceptance, hook decision, durable summary attempts/usage, complete `retainedTail`, result entry, abort/failure, and structural resume. + - Acceptance: exact manual-compaction traces and every crash boundary; hook-supplied summaries obey the same persisted entry contract. +- [ ] **C2 — threshold auto-compaction.** Dependencies: C1, H4. + - Run compaction inside the active run at checkpoints without a nested operation and continue the assistant loop. + - Acceptance: append-only context holds except at the compaction boundary; repeated compaction retains the previous checkpoint tail. +- [ ] **C3 — overflow recovery.** Dependencies: C2, H2. + - Classify recoverable overflow/length results, discard them after usage accounting, compact, retry once per conversational input, and fail boundedly. + - Acceptance: every provider shape and crash row from sections 6 and 20, including hook decline and `length → length`. +- [ ] **N1 — move-first navigation.** Dependencies: C3. + - Add acceptance, abandoned-branch preparation, hook/generated summary, move commit, post-move summary/fact writes, abort/failure, and structural resume. + - Acceptance: every navigation crash row, including regeneration after a post-move crash and target/source validation. + +### Track O — observability and core completion + +These packages merge O1 → O2 → O3 → O4 after N1. O4 also requires J5. They may not modify `packages/coding-agent/**`. + +- [ ] **O1 — snapshots and event completeness.** Dependencies: N1, I2. + - Finish live lane/session snapshots, event filtering, streaming/running-tool state, and all section 10 event insertion points. + - Acceptance: event nesting/order tests and attach-mid-operation snapshot tests with no subscription gap. +- [ ] **O2 — runtime telemetry instrumentation.** Dependencies: O1, I0. + - Insert spans at effect implementation boundaries using the already-landed telemetry contracts, including parallel tool children and resumed operation correlation. + - Acceptance: exact span trees for success, failure, suspend/resume, retry, compaction, and parallel tools; no content or secrets in defaults. +- [ ] **O3 — action-prefix and race audit.** Dependencies: O2. + - Complete Tier C for every race row, mechanically reopen every action prefix, compare automatic/manual logs, and verify reducer/live-state fixed points. + - Acceptance: every race row has both orders and no documented crash action lacks a reopen test. +- [ ] **O4 — backend parity and final core audit.** Dependencies: J5, O3. + - Run the complete storage/recovery matrix across memory, JSONL, and SQLite; remove dead agent/storage declarations and compatibility comments; verify exports/declarations and `./node`; update changelogs and core documentation. + - Acceptance: all non-e2e tests and `npm run check` pass, no active harness operation remains scaffolded, `packages/coding-agent/**` is unchanged, and the worktree is clean. + +### Dependency, priority, and merge summary + +The serial storage lane is **R0 → J0 → J1 → J2 → J3 → J4 → J5**. The reducer lane is **R0 → R1 → R2 → R3**. The loop lane is **I0 → L1 → L2 → L3**. The effects lane is **R2 → I3 → I4 → I5**, with I4 also requiring I0, I1, and L3. Before H0, the convergence gate is **F0 + R3 + I2 + I5**. + +The runtime merge lane is strictly **H0 → H1 → H2 → H3 → H4 → H5 → H6 → H7 → H8 → C1 → C2 → C3 → N1 → O1 → O2 → O3 → O4**. J5 may land independently at any time before O4. This ordering prevents concurrent rewrites of `agent-harness.ts`, assigns every public method, and ensures every live path lands only after its reducer, telemetry, interception, and effect boundaries exist. ## 22. Required reading -For a fresh implementation session, in this order. This document wins over anything older; `harness.md` (v1 of this design) is superseded and must not be followed where they disagree. +For a fresh implementation session, in this order. This document wins over older harness designs. 1. `packages/agent/docs/harness-v2.md` — this document. -2. `packages/agent/src/agent-loop.ts` — the loop to split into the section 14 building blocks. -3. `packages/agent/src/agent.ts` — queues, continuation, abort, settlement to preserve in spirit. -4. `packages/agent/src/harness/agent-harness.ts` — the harness being replaced. -5. `packages/agent/src/harness/types.ts` — current entry union and storage contract. -6. `packages/agent/src/harness/session/session.ts` — context build, projectors, entry creation. -7. `packages/agent/src/harness/session/jsonl-repo.ts` — v3 format and reload. -8. `packages/agent/src/harness/session/memory-repo.ts` — in-memory parity. -9. `packages/agent/src/harness/messages.ts` — message conversion (toProviderMessages default). -10. `packages/agent/src/harness/compaction/compaction.ts` — preparation, split-turn summaries. -11. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing. -12. `packages/coding-agent/src/core/agent-session.ts` — old behavior to preserve in spirit. -13. `packages/coding-agent/src/core/extensions/runner.ts` — old extension error isolation. -14. `packages/storage/sqlite-node/src/sqlite/storage/index.ts` — current engine: transactions, sequences, branch materialization. -15. `packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts` — the branch cache being generalized. -16. `packages/storage/sqlite-node/src/sqlite/repo.ts` — create/open/fork. -17. `packages/coding-agent/docs/session-format.md` — v3 JSONL, the compatibility target. -18. `packages/agent/docs/observability.md` — telemetry background; section 18 defines context propagation. +2. `packages/agent/src/harness/session/types.ts` — v4 entries, records, storage, and repository contracts. +3. `packages/agent/src/harness/session/session.ts` — session validation and lane-bound views. +4. `packages/agent/src/harness/session/memory.ts` — reference backend. +5. `packages/storage/sqlite-node/src/sqlite/repo.ts` — v4 SQLite repository, leases, and forks. +6. `packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts` — branch cache queries. +7. `packages/agent/src/harness/agent-harness.ts` — v2 public API scaffold. +8. `packages/agent/src/agent-loop.ts` — the loop to split into the section 14 building blocks. +9. `packages/agent/src/agent.ts` — queues, continuation, abort, settlement to preserve in spirit. +10. `packages/agent/src/harness/messages.ts` — message conversion (`toProviderMessages` default). +11. `packages/agent/src/harness/compaction/compaction.ts` — preparation and split-turn summaries. +12. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing. +13. `packages/coding-agent/src/core/agent-session.ts` — read-only behavioral reference; do not modify it. +14. `packages/coding-agent/src/core/extensions/runner.ts` — read-only error-isolation reference; do not modify it. +15. `packages/coding-agent/docs/session-format.md` — read-only v3 JSONL format reference. diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md deleted file mode 100644 index b1e5d63a4ed..00000000000 --- a/packages/agent/docs/harness.md +++ /dev/null @@ -1,2320 +0,0 @@ -# Durable AgentHarness plan - -## 1. Goals - -- **Durable runs.** An accepted prompt is a durable operation. After a process crash, a new process restores the session and resumes the run from the last safe boundary. Every valid session prefix is a recoverable state. -- **Correct branch semantics.** Runs and queued messages are anchored to the branch they were accepted on. Navigation and compaction write multiple records; a crash between records must leave either a valid pre-operation state or one that recovery completes — never a half-moved cursor or a summary on the wrong branch. -- **Harness API.** Passive events to observe execution; awaited hooks to transform harness behavior (context, requests, tools, run boundaries). Extensions build on these. -- **Observability.** Everything is instrumentable — down to provider request/response internals — for logging and tracing (e.g. OTel), without going through the hook system. -- **UI model.** Atomic snapshot plus live event stream. No event replay; reconnect means new snapshot. -- **Single writer, parallel refs.** Exactly one harness writes a session at a time, enforced by the serving layer; restore treats impossible states from interleaved writers as corruption. Within that one writer, a session hosts one or more **refs** — named movable leaf pointers, each running at most one operation at a time, in parallel with its siblings (section 6). Interactive use never sees more than the default ref. -- **Old sessions load.** Existing session files open unchanged and restore as idle. Session entry types and tree semantics are unchanged. - -## Non-goals - -- **Exactly-once hook side effects.** What a hook hands to the harness (queue a message, append an entry) is durable once the call resolves and survives crashes. What a hook does on its own (HTTP calls, file writes) is invisible to the harness: after a crash it cannot know how far an interrupted handler got, so handlers are never re-run on resume. Hooks needing crash-safe external effects must be idempotent themselves, e.g. keyed by operation ID. -- **Provider stream resumption.** An interrupted provider request is retried or abandoned; partial streams are never persisted. - -## 2. Terminology - -The onion, outside in: - -- **Harness** — executes runs against one session: drives provider requests and tools, manages queues, emits events, applies hooks. Exactly one harness writes a session at a time. -- **Session** — the durable state: an ordered, append-only log of entries. Two views over the same log: the tree (session entries, conversational state) and orchestration history (harness entries). -- **Session entry** — an entry in the session tree (`message`, `compaction`, `leaf`, ...). Defines conversational ancestry via `parentId`; visible in transcripts and model context. -- **Harness entry** — a private orchestration fact (operation started, tool started, ...) used to resume a run after abnormal termination. Lives in the same log, never in the tree: no parent, never the leaf, never in model context, never emitted publicly. -- **Ref** — a named, movable pointer to a leaf of the tree plus the work serialized on it: one active operation, its own queues, its persisted config derived from the path behind its leaf. Every session has the default ref `main`; embedders may create more (section 6). - -Execution: - -- **Operation** — a run, a manual compaction, or a tree navigation, executed on one ref. At most one operation is active per ref at any time; restore treats a log with two unmatched operation starts on the same ref as corruption. -- **Run** — one accepted prompt through all automatic continuations (tool calls, steering, follow-ups, auto-compaction) until the harness is idle again. Durable; may span process restarts. -- **Step** — one generation (plus retries), the resulting assistant response, and the complete tool batch it requested. A run is a sequence of steps. -- **Generation** — one billable cycle of producing a result (assistant response, compaction or branch summary); may span several physical provider requests. A step contains one or more generations (retries). -- **Checkpoint** — the safe point between steps where queued messages are consumed, deferred writes flush, and compaction is considered. -- **Deferred write** — a session write requested while a ref has a step in flight: hooks or the application appending a custom message, or changing model/thinking level/active tools. Applying it immediately would insert content before the in-flight request's tail — splitting an assistant tool-call message from its tool results, and violating the append-only context invariant (section 5) that keeps provider KV caches valid. So it is accepted durably on request and applied at the next checkpoint of that ref. While the ref is idle, the same writes apply immediately. -- **Resume** — continuing an unfinished run in a new process, possibly mid-step. - -API: - -- **Event** — a passive public observation. Cannot alter execution; not persisted or replayed. -- **Hook** — an awaited public interception point. Can transform or block execution. -- **Snapshot** — an atomic capture of current harness state, delivered race-free with every event after it (section 8). -- **Config** — model, thinking level, system prompt, active tools, resources. Getters return the latest accepted value. Setters while a step is in flight become deferred writes; the in-flight request and tool batch are not affected. - -Central invariant: - -> Session entries define what the conversation is. Harness entries define what the harness did, in what order. Log order determines orchestration history; `parentId` and per-ref leaf pointers determine branches; harness entries never alter tree topology. - -## 3. Architecture - -```mermaid -flowchart TD - App[Application / UI] -->|prompt, steer, abort, config| Harness - Harness -->|snapshot + events| App - Harness -->|hooks + events| Ext[Extensions] - Harness --> Loop[Step primitives
request / tools] - Loop --> Provider[LLM provider] - Loop --> Tools[Tools] - Harness --> Session - Session --> Storage[(JSONL / memory / SQLite)] - Harness -.->|telemetry| Otel[Observability] -``` - -- **Step primitives** — the low-level building blocks split out of today's monolithic loop: one provider request, one tool batch, one step. Own no durable state; `runAgentLoop()` remains as a compatibility wrapper over them. -- **Harness** — the driver: accepts operations, sequences steps and checkpoints, owns queues, retry, compaction, navigation, cancellation, recovery. The only writer of harness entries. -- **Session** — owns the log and the tree view; validates and appends entries, answers branch/context queries. -- **Storage** — append + read for one session. No orchestration knowledge. - -The public surface is harness methods, events, hooks, snapshots, config, session tree queries/writes, and the telemetry stream. Harness entries, their schemas, and recovery logic are private: nothing outside harness and storage reads or depends on them. - -## 4. Run and step lifecycle - -### Run states - -States are per ref: each ref is independently Idle, Running, Cancelling, or Suspended. Faulted is the exception — an append failure faults the whole harness, every ref included, because none of them can record what it does. - -```mermaid -stateDiagram-v2 - [*] --> Idle - Idle --> Running: prompt accepted - Running --> Idle: finished - Running --> Cancelling: abort - Cancelling --> Idle: reconciled - Running --> Faulted: append failure - Suspended --> Running: resume - Suspended --> Cancelling: abort -``` - -- **prompt accepted** — the operation start is durable. Before that, a crash means the prompt never happened. -- **finished** — outcome `completed` or `failed`; the finish is durable, then the harness is idle. -- **abort** — cancellation is recorded durably, active effects are signalled, `abort()` returns. Reconciliation (tool results for unresolved calls, closing aborted assistant message) runs to completion in the background. -- **Suspended** — restore found an unfinished run. Nothing executes until `resume()`; `abort()` cancels without resuming execution. -- **Faulted** — a session append failed (disk full, I/O error). The harness stops all effects and rejects everything: it can no longer record what it does. The log is not corrupted, just a valid prefix, as after a crash. Fix the cause, reopen, restore: the run shows as Suspended. Log corruption is different: restore rejects it, no automatic path forward. - -### Steps and checkpoints - -A running operation alternates between steps and checkpoints: - -```mermaid -flowchart TD - CP[Checkpoint] --> DW[Flush deferred writes] - DW --> Q[Consume queued messages] - Q --> AC{Context too big?} - AC -->|yes| C[Auto-compact] --> S - AC -->|no| S[Step] - S --> More{Tool calls or
queued messages?} - More -->|yes| CP - More -->|no| End[before_run_end hook] - End -->|returned follow-up| CP - End -->|nothing| F[Finish run] -``` - -A step: - -```mermaid -flowchart LR - R[Request] -->|retry| R - R --> A[Assistant response persisted] - A --> T[Tool batch] --> E[Step end] -``` - -- A step with tool calls always forces another checkpoint + step; the model must see its tool results answered. -- `steer()` and `followUp()` enqueue durably at any time during a run — acceptance appends a harness entry (tree-neutral, so mid-step is fine); the message itself becomes a session entry at its consumption point. Steering is injected at the next checkpoint, before the next request. Follow-ups are consumed only when tool continuation and steering are exhausted — when the model would otherwise stop. -- Auto-compaction is evaluated against the prospective context for the next request, after queued messages and deferred writes are applied. -- `before_run_end` runs when nothing is pending: no tool continuation, no queued messages. It may return or enqueue follow-ups, each durable on acceptance. The run finishes only when nothing is pending afterwards. - -### Resume - -Resume continues the existing run; it never starts a new one: - -- Entry point is wherever the log ends: mid-step (retry the request, or reconcile an unfinished tool batch) or at a checkpoint. -- `before_run` ran when the prompt was accepted and is not called again; `before_resume` is. -- Pending queued messages and deferred writes accepted before the crash are still pending and apply normally. -- Per-request and per-tool hooks run for work actually performed after resume. - -## 5. Session and the log - -### One log, two views - -A session is an append-only log. JSONL implements this literally (one JSON object per line); other backends may use tables and indices as long as ordering and semantics match. A record is either a **session entry** (tree) or a **harness entry** (orchestration). Log order is total; tree structure comes only from `parentId` on session entries. - -Harness entries exist to resume a run after abnormal termination. They record accepted operations, issued provider requests, started tools, queued messages, and deferred writes, so a new process can tell how far execution got and continue without repeating effects. Nothing reads them during normal operation. - -```mermaid -flowchart TB - subgraph LOG ["log (append order, top to bottom)"] - direction TB - h1(["op_started op-1"]) - u1["message user U1"] - h2(["generation_started"]) - a1["message assistant A1"] - h3(["tool_started call-1"]) - h4(["queue_enqueued steer S1"]) - t1["message toolResult T1"] - s1["message user S1"] - a2["message assistant A2"] - h5(["op_finished op-1"]) - h1 ~~~ u1 ~~~ h2 ~~~ a1 ~~~ h3 ~~~ h4 ~~~ t1 ~~~ s1 ~~~ a2 ~~~ h5 - end - u1 -->|parent| a1 -->|parent| t1 -->|parent| s1 -->|parent| a2 -``` - -Rounded records are harness entries: no `parentId`, never the leaf, never in model context or transcripts, invisible through `SessionTree`. Rectangular records connected by `parent` arrows are session entries; the tree is that chain. - -Consequences: - -- Harness entries can be appended mid-step (steering, deferred-write acceptance) without touching tree ordering. -- Navigation, compaction, and forks (section 13) operate on the tree; orchestration facts are never hidden by a compaction barrier or copied into a fork. -- Old files contain no harness entries and restore idle. - -### Durability rule - -> Before an effect: append an intent entry naming what will happen and the ids it will produce. After the effect: append the result as a session entry with those ids. - -No multi-record atomicity. Any log prefix is a valid state: an intent without its result means in flight or interrupted; recovery (section 12) decides completion per intent type. - -### Append-only context - -> Across the requests of a branch, provider context only ever grows at the tail. Inserting content before the previous request's tail invalidates the provider's KV cache from the insertion point onward — silently multiplying token cost. - -This invariant, not just tool-call adjacency, is why mid-step writes defer to checkpoints: checkpoint application and queue consumption append at the tail, so the cached prefix survives every request. Compaction is the one deliberate exception — it trades a full cache invalidation for a smaller context, knowingly. - -Two mechanisms carry mid-run content, with deliberately different contracts: - -- **Queues** carry conversational intent: steer/followUp die on abort (payloads returned so a client can requeue), nextRun survives. The session entry lands at the consumption point — the position the model actually first saw it. -- **Deferred writes** carry facts: they survive abort and are applied even during cancellation reconciliation. Both are durable at acceptance. - -Custom entries enter provider context only through registered projectors (`entryProjectors`: custom entry → context messages, evaluated at context build); without one they project to nothing and cannot affect the cache. Corollaries: projector output must be stable across context builds for entries already in context, and registering a projector later re-animates existing entries at their historical positions — a one-time cache break, the application's responsibility. - -### Provisioned ids - -Intent entries carry ids of session entries that do not exist yet: `tool_started.resultEntryId`, `queue_enqueued.target.id`, the operation start's initial message ids. The later session entry uses exactly that id. An intent is fulfilled iff an entry with its provisioned id exists; an id collision with different content is corruption. - -```ts -/** A session entry payload with its id pre-allocated. parentId and timestamp - are assigned when the entry is actually appended: it becomes a child of the - then-current leaf, exactly like a normal append. */ -type ProvisionedEntry = Omit; -type ProvisionedMessage = ProvisionedEntry; -``` - -### Harness entry schemas - -```ts -interface HarnessEntryBase { - id: string; - seq: number; // position in the chronological log - ref: string; // the ref this record belongs to ("main" in a single-ref session) - timestamp: string; -} -// Session entries carry no ref — the tree is shared between refs (common -// prefixes), so a ref field would fake ownership that does not exist. Which -// ref appended a session entry is derivable: a ref's operation appends a -// chain from its anchor, so membership is parentId linkage into that chain — -// one pass over the bounded tail, in seq order. Leaf records are the -// exception: they carry ref explicitly, they ARE the per-ref pointer. -// Old files: everything reads as "main". -// Entries that belong to an operation carry runId: the id of that operation's -// operation_started entry. Not on the base: queue_enqueued(nextRun) belongs to -// no operation — it targets the next run, and can be accepted while idle. - -// The durable acceptance boundary for an operation. Everything decided -// before acceptance is persisted here: before_run output, queued next-run -// consumption, provisioned ids for structural results. -interface OperationStartedEntry extends HarnessEntryBase { - type: "operation_started"; - sourceLeafId: string | null; // the ref's leaf at acceptance - intent: - | { - kind: "run"; - /** Prompt + before_run injections, full payloads with provisioned ids. */ - initialMessages: ProvisionedMessage[]; - /** Set iff before_run overrode the system prompt; fixed for the run. - Absent: the systemPrompt config callback is evaluated per request - (sees current active tools — mid-run tool changes rebuild the prompt). */ - systemPromptOverride?: string; - resumeData?: Record; // per extension id - } - | { - kind: "compaction"; - customInstructions?: string; - resultEntryId: string; - } - | { - kind: "navigation"; - targetId: string; - destinationLeafId: string | null; - summarize: boolean; - customInstructions?: string; - label?: string; - summaryEntryId?: string; - labelEntryId?: string; - leafEntryId: string; - }; -} - -// Cancellation is durable the moment abort() resolves. Clears this -// operation's steer/follow-up queue items; next-run items survive. -interface OperationCancelledEntry extends HarnessEntryBase { - type: "operation_cancelled"; - runId: string; - reason: "user" | "shutdown"; -} - -// Closes the operation. Cursor-neutral: cannot undo a navigation's leaf move. -// outcome "failed" is a durable, orderly failure: retry attempts exhausted, -// compaction/summary generation permanently failing, required model missing. -// outcome "cancelled" is a structural operation declined by its hook -// (before_compaction/before_navigation cancel). Either way the operation is -// closed; nothing resumes. Distinct from Faulted (section 4): a fault means -// appends fail, so no finish entry can be written and the operation restores -// as suspended instead. -interface OperationFinishedEntry extends HarnessEntryBase { - type: "operation_finished"; - runId: string; - outcome: "completed" | "aborted" | "failed" | "cancelled"; - error?: { code: string; message: string }; // safe fields only, no payloads -} - -// Appended before each generation cycle the harness bills to a provider — an -// uncertainty marker ("a request may have gone out and been billed") and the -// durable crash-loop bound: attempt counts survive restarts. One entry per -// cycle, even when a cycle makes several physical requests (split-turn -// compaction runs two); recovery granularity is the result entry, so finer -// accounting buys nothing. Position and completion are positional: attempts -// after the newest session entry belong to the current request cycle, and a -// session entry committing closes the cycle. Validation: attempt numbers are -// consecutive within each gap between session entries (a gap is always -// single-purpose — compaction either commits its entry, closing the gap, -// or fails the run). All positional rules are per ref: "newest session -// entry" means the newest session entry chained by this ref's operation -// (parentId membership, see above) — the per-ref partition is what makes -// positional reduction safe under interleaved refs. -interface GenerationStartedEntry extends HarnessEntryBase { - type: "generation_started"; - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; // 1-based within the current cycle - model: { provider: string; modelId: string }; -} - -// Appended after before_tool and validation, before the effect starts. -// assistantEntryId + toolIndex is the durable invocation identity. -interface ToolStartedEntry extends HarnessEntryBase { - type: "tool_started"; - runId: string; - assistantEntryId: string; - toolIndex: number; - toolCallId: string; - toolName: string; - effectiveArgs: Record; // post-before_tool - resultEntryId: string; // provisioned - replay: "never" | "safe"; -} - -// steer()/followUp()/nextRun() acceptance. The message payload (any -// AgentMessage that converts to a user LLM message) travels here; the session -// entry appears at the consumption point. Steer/follow-up items resolve within -// their run. Next-run items are consumed by the next run-kind operation on the -// same ref, embedded in its operation_started initialMessages; compaction and -// navigation pass through without consuming. Pending items therefore always -// sit after the ref's last run-kind operation_started — recovery never scans -// further back. -interface QueueEnqueuedEntry extends HarnessEntryBase { - type: "queue_enqueued"; - queue: "steer" | "followUp" | "nextRun"; - /** steer/followUp: their active run. Absent for nextRun — the item belongs - to no operation until the next run consumes it. */ - runId?: string; - target: ProvisionedMessage; -} - -// SessionTree write or config setter accepted mid-step: message, custom entry, -// label, session name, or config change (model_change, ...). Applied in -// acceptance order at the next checkpoint — live or during recovery, each -// target is appended as a child of the then-current leaf; that is why -// ProvisionedEntry omits parentId. -interface WriteDeferredEntry extends HarnessEntryBase { - type: "write_deferred"; - runId: string; - target: ProvisionedEntry; // full payload, provisioned id -} -``` - -Blocked, invalid, or truncation-failed tool calls append no `tool_started` — no external effect begins; they go straight to a synthetic error result. - -### Log invariants - -Restore rejects a log violating any of these as corrupt: - -- at most one unmatched `operation_started` per ref -- operation events reference an existing operation of the same ref; finish/cancel never precede start -- attempt numbers are consecutive from 1 within each gap between session entries of the same ref -- tool invocation identities are unique; their assistant entries exist -- provisioned ids never collide with differing content -- a ref's active run cursor moves only through that run's appends - -### SessionTree - -`SessionTree` is new: the tree-facing contract over the log. `Session` implements it plus the log side below. Each ref exposes its own harness-owned `SessionTree` view (`ref.session`; `harness.session` is main's): branch-scoped reads default to that ref's leaf, appends chain to it, and writes defer while that ref has a step in flight — an immediate append would land before the in-flight request's tail, breaking tool-call adjacency and the append-only context invariant. Writes through one ref's view never defer because another ref is busy. Standalone `Session` writes apply immediately. - -```ts -/** Filters and paging. Omit type to match every entry. */ -interface EntryQuery { - type?: SessionTreeEntry["type"]; - customType?: string; // for type: "custom" - order?: "newestFirst" | "oldestFirst"; // default newestFirst - limit?: number; - cursor?: EntryCursor; // continue a previous page -} - -/** Where a branch scan starts and stops. Defaults: the whole path, leaf to root. */ -interface BranchBounds { - /** Leaf end of the path. Default: the view's ref leaf. Needed to query another branch or an old compaction tail. */ - start?: string; - /** Scan ends after the first matching entry, inclusive. */ - stopAtType?: SessionTreeEntry["type"]; - stopAtId?: string; -} - -// Generic over metadata; finders return Extract. -interface SessionTree { - // Reads — always allowed - getMetadata(): Promise; - getEntry(id: string): Promise; - getLeafId(): Promise; - getStats(): Promise; - - // Global facts — latest-wins records outside the tree, not branch-scoped. - // Setter naming is deliberate: "append" is reserved for tree writes. - getName(): Promise; - setName(name: string): Promise; - getLabel(id: string): Promise; - setLabel(targetId: string, label: string | undefined): Promise; - - /** Session-wide: all branches, log order. */ - findEntries(query?: EntryQuery): Promise; - - /** Sugar: findEntries with limit 1. */ - findEntry(query?: Omit): Promise; - - /** Branch-scoped: the path start (default leaf) → root. */ - findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise; - - /** Sugar: findEntriesOnBranch with limit 1. */ - findEntryOnBranch(query?: Omit & BranchBounds): Promise; - - // Writes — immediate on standalone Session, deferred while a harness runs. - // Resolve on durable acceptance; the returned string is the provisioned entry - // id the session entry will carry when applied. Safe to call from hook and - // event handlers at any point. - appendMessage(message: AgentMessage): Promise; // includes custom app message types - appendCustomEntry(customType: string, data?: unknown): Promise; - - /** Writes accepted but not yet applied, in acceptance order. Empty on standalone Session. */ - getPendingWrites(): { id: string; entry: SessionTreeEntry }[]; -} -``` - -Query semantics — a branch scan is: take the path from `start` (default: active leaf) to root, walk it in `order` direction, stop after a `stopAt` match (inclusive), filter, apply `limit`/`cursor`: - -- `newestFirst` walks leaf→root: `stopAtType: "compaction"` ends at the **newest** compaction — the context window. -- `oldestFirst` walks root→leaf: the same query ends at the **oldest** compaction. The barrier is found in walk direction. For the newest-compaction segment in chronological order, fetch `newestFirst` and reverse; it is one context window, not a big list. -- `type`/`customType` filter results; a `stopAt` entry is returned only if it passes the filter. -- These subsume `getBranch()` and `getPathToRootOrCompaction()`: context build is `findEntriesOnBranch({ stopAtType: "compaction" })`; old-style compaction tails are a second call with `start: compaction.parentId, stopAtId: firstKeptEntryId`. -- Extension patterns: effective state = `findEntryOnBranch({ type: "custom", customType })`; branch collections = `findEntriesOnBranch({ type: "custom", customType })`; global inventory = `findEntries({ type: "custom", customType })`. -- `SessionTree` has no cursor mutation; tree navigation is `navigateTree()` on the harness. - -> **Read consistency:** finders and `getEntry()` return committed entries only. A deferred write is not in the tree until applied; a handler that appends and immediately queries will not see its own write. A pending entry has no `parentId` yet, and any overlaid position would be a guess that later entries invalidate. Pending writes are visible via `getPendingWrites()` and the snapshot, correlated by provisioned id. - -### Session API changes - -`Session` implements `SessionTree` and gains the log side, used only by the harness and recovery: - -```ts -class Session implements SessionTree { - appendHarnessEntry(entry: HarnessEntryInput): Promise; - /** Full chronological log — session and harness entries interleaved. */ - getLog(options?: { afterSeq?: number; limit?: number }): Promise; - - /** Typed harness-entry queries, same shape as the tree finders. SQLite - serves them from an indexed harness-entry table. */ - findHarnessEntries(query?: { type?: HarnessEntry["type"]; ref?: string; runId?: string; afterSeq?: number; order?: "newestFirst" | "oldestFirst"; limit?: number }): Promise; - findHarnessEntry(query?): Promise; // limit 1 -} -``` - -Restore reads are bounded regardless of session length, per ref: the ref's latest `operation_started`/`operation_finished` (index seek) locates its active operation, and everything else recovery needs — attempt counts, tool starts, pending queue items, deferred writes — lives at `seq` greater than that operation's start, filtered by ref (range scan). Pending next-run items cannot sit further back than the ref's last run-kind `operation_started` because run acceptance consumes them (see `QueueEnqueuedEntry`); SQLite stores ref and operation kind as columns, so locating them is an index seek. - -Changes to the existing contract: - -- `getPathToRootOrCompaction()` and `getBranch()` are removed — subsumed by `findEntriesOnBranch`. The duplicated walk logic in the JSONL and SQLite backends is deleted. -- `buildContext()` is reimplemented on the finders: one branch scan with `stopAtType: "compaction"`, plus the old-style tail scan (`start: compaction.parentId, stopAtId: firstKeptEntryId`) when the compaction entry predates embedded tails. -- Config derivation leaves `buildContext()`: model/thinking/active tools are point queries (`findEntryOnBranch`), correct across compaction barriers — and per ref, since each ref queries from its own leaf. -- Labels and session name are **de-treed**: `LabelEntry` and `SessionInfoEntry` records lose `parentId` and live outside the tree as global latest-wins facts (single writer makes log order a valid last-writer-wins order). They no longer appear in branch queries; old tree-entry forms convert on read. Fork handling: section 13. -- Leaf records gain `ref`; a session keeps one leaf pointer per ref (absent = `main`, which is how old files read). -- `custom_message` entries convert to custom agent messages on read; the entry type is retired from the write path. -- `getStorage()` is gone: raw storage is unreachable, all writes flow through `Session`, and `Session` is the single writer the log format assumes. -- JSONL becomes format v4: same file, same one-JSON-object-per-line, harness entries interleaved. v3 files load unchanged (zero harness entries, restore idle). - -Storage backends implement append + read + the finder queries; they know nothing about operations, queues, or recovery (section 14). - -## 6. Refs - -A **ref** is a named, movable pointer to a leaf of the tree, plus the work serialized on it. It is what a git branch is — a name attached to a position, advanced by new work, movable to any point without rewriting history — fused with its worktree: at most one operation runs on a ref at a time, exactly as git refuses to check the same branch out into two worktrees. One intuition git users must extend: navigation can move a ref to *any* entry (like `git reset`), not only forward. - -```text -tree (shared, append-only) refs -a ── b ── c ── d main → d - └── e ── f slack:1719432.0021 → f -``` - -- Every session has the default ref **`main`**. `AgentHarness` implements the ref surface directly (section 7): `harness.prompt(...)` *is* main's prompt. Interactive pi never creates a second ref — one active branch, resume-where-you-left-off, `/tree` — nothing about that model changes. -- Embedders create refs keyed by external identities: Slack channel = session + `main`, each thread = a ref anchored at the pinged entry; each email thread = a ref. The platform's UI is the ref picker; no client browses refs abstractly, and end users never see the word. -- Each ref owns: its leaf; one active operation; its steer/followUp/nextRun queues; its pending deferred writes; and its persisted config view — model, thinking level, and active tools are point queries on the path behind its leaf, so two refs run different models without knowing of each other. Harness-global stay: tool implementations, resources, stream options, retry policy — registries and runtime capabilities are shared, activation is per-ref and branch-anchored. -- Refs are pointers, not containers. Deleting one removes the name, never entries. Two refs at the same entry diverge on their next append. `navigateTree` moves one ref. -- Refs run in parallel under one harness: one writer, one log, interleaved records partitioned by `ref`. Cross-process concurrency stays out of scope — all traffic for a session routes to the process holding its harness. -- After a crash, every ref with an unfinished operation restores suspended, independently; `create()` returns them all (section 7). - -**Why refs are not trees.** Harness entries carry `ref` but no parent pointers, deliberately. Within one ref, operations are serialized and there is one writer — so for records filtered by ref, log order *is* causal order. A parent pointer would repeat what `ref` + append order already say, and add validation surface (parent exists, chain does not fork, chain belongs to the operation) with no consumer. Parenting earns its keep only when order stops being reliable: concurrent writers to the *same* ref (excluded by design) or replication without a total order (section 17). - -## 7. Public API - -```ts -const { harness, suspended } = await AgentHarness.create({ session, models, model, ... }); - -for (const s of suspended) { - await harness.ref(s.ref)!.resume(); // or .abort(); interactive pi: 0 or 1, always "main" -} -await harness.prompt("..."); -``` - -The existing surface stays. New: `create()` replaces the constructor, `resume()` continues a suspended operation, `watch()` provides snapshots (section 8), `hooks`/`events` replace `on()`/`subscribe()` (sections 9, 10) — and the operation surface is factored into `AgentRef`, which `AgentHarness` implements for `main`. There is one operation surface, defined once. - -```ts -interface AgentHarnessOptions { - session: Session; - /** Provider collection for all requests: steps, compaction, branch summaries. */ - models: Models; - /** model, thinkingLevel, activeToolNames: initial values only. If a ref's - branch has persisted config entries, the session wins; these apply to fresh - sessions and refs without config history. An unresolvable persisted model - surfaces in suspended[].missing / falls back with a warning, mirroring the - old coding agent. */ - model: Model; - thinkingLevel?: ThinkingLevel; - tools?: TTool[]; - activeToolNames?: string[]; - toolContext?: TContext | (() => TContext | Promise); - systemPrompt?: AgentHarnessSystemPrompt; - resources?: AgentHarnessResources; - /** Curated provider request options (transport, headers, timeouts, provider-internal retries). Snapshotted at request start. */ - streamOptions?: AgentHarnessStreamOptions; - /** Harness-level retry for failed requests (steps, compaction, branch summaries). Attempt counts are durable; a restart never resets them. */ - retry?: RetryPolicy; - compaction?: CompactionSettings; // enabled, reserveTokens, keepRecentTokens - steeringMode?: QueueMode; - followUpMode?: QueueMode; - /** Converts AgentMessages (including app custom types via CustomAgentMessages) - to provider messages before each request. Default: exported - defaultConvertToLlm, handling bashExecution, custom, branchSummary, - compactionSummary; standard messages pass through. Also used at - prompt/queue acceptance to validate that a submitted AgentMessage - converts to a user message. Reordering/pruning is not its job; that is - transform_context. */ - convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; -} - -/** The operation surface of one ref. AgentHarness implements it for main; - harness.ref(name) returns the same surface for any other ref. */ -interface AgentRef { - readonly name: string; // "main" on the harness itself - getLeafId(): Promise; - - // Operations. Never throw — they resolve with a result value in every case - // (see "Results, not exceptions" below). Message forms mirror Agent: any - // AgentMessage accepted here (and by the queues) must convert to a user LLM - // message via convertToLlm; validated at acceptance. At most one operation - // is active per ref; operations on different refs run concurrently. - prompt(text: string, images?: ImageContent[]): Promise; - prompt(message: AgentMessage | AgentMessage[]): Promise; // e.g. sendMessage triggerTurn - skill(name: string, additionalInstructions?: string): Promise; - promptFromTemplate(name: string, args?: string[]): Promise; - compact(options?: { customInstructions?: string; settings?: Partial }): Promise; - navigateTree(targetId: string, options?: NavigateTreeOptions): Promise; - - /** New. Continue this ref's suspended operation to its durable end. */ - resume(): Promise; - - /** Existing. Now durable: cancellation is recorded before effects are signalled; returns without waiting for reconciliation. No-op while this ref is idle. */ - abort(): Promise; - - // Queues (existing) — now durable on resolve, and per ref (nextRun included: - // consumed by this ref's next run). Payloads are AgentMessage: user messages - // and custom extension messages (sendMessage deliverAs). As with prompt(), - // every AgentMessage must convert to a user LLM message via convertToLlm; - // validated at acceptance. - steer(text: string, images?: ImageContent[]): Promise; // requires active run - steer(message: AgentMessage): Promise; - followUp(text: string, images?: ImageContent[]): Promise; // requires active run - followUp(message: AgentMessage): Promise; - nextRun(text: string, images?: ImageContent[]): Promise; // any time; was nextTurn() - nextRun(message: AgentMessage): Promise; - - // Idle coordination — this ref's idleness. - waitForIdle(): Promise; // existing - runWhenIdle(callback): Promise; // new; runtime-only, not durable - - // Persisted, branch-anchored config — per ref by construction: session - // entries on the path behind this ref's leaf, restored by point queries. - // Setters mid-step become deferred writes on this ref. - getModel() / setModel(model) - getThinkingLevel() / setThinkingLevel(level) - getActiveTools() / setActiveTools(toolNames) // unknown names reject - - /** This ref's SessionTree view (section 5): branch reads default to this - ref's leaf, appends chain to it, writes defer while this ref runs. - Replaces appendMessage(). */ - session: SessionTree; - - /** Scoped: this ref's transcript, run state, queues, and events (section 8). */ - watch(): Promise<{ snapshot: RefSnapshot, start, unsubscribe }>; -} - -class AgentHarness implements AgentRef { - /** Opens the session log, restores state, starts no effects. Replaces the - constructor. One suspended entry per ref with an unfinished operation. */ - static create(options: AgentHarnessOptions): Promise<{ - harness: AgentHarness; - suspended: SuspendedOperation[]; - }>; - - // Ref management. Names are app-chosen keys ("slack:1719432.0021"). - ref(name: string): AgentRef | undefined; // lookup, never creates - createRef(name: string, at: string | null): Promise; // anchor at an entry or root - deleteRef(name: string): Promise; // pointer only; rejected while its - // operation is active or suspended; - // "main" cannot be deleted - refs(): RefInfo[]; - - // Harness-global config — registries and runtime capabilities, shared by - // all refs. Tool implementations are code and cannot persist; the active - // set (names) is what persists, per ref. - getTools() / setTools(tools, activeToolNames?) // registry; active set applies to main - getResources() / setResources(resources) - getStreamOptions() / setStreamOptions(streamOptions) - getRetryPolicy() / setRetryPolicy(policy) // new - getCompactionSettings() / setCompactionSettings(s) // new - getSteeringMode() / setSteeringMode(mode) - getFollowUpMode() / setFollowUpMode(mode) - - /** Session-wide observer: refs inventory snapshot plus the unfiltered event - stream (section 9). No transcripts — compose with ref.watch() per ref. */ - watchSession(): Promise<{ snapshot: SessionSnapshot, start, unsubscribe }>; - - // Harness-global; every hook/event payload carries ref (sections 9, 10). - hooks: ...; - events: ...; - - /** New. Detach cleanly — see semantics below. Does not abort operations; they stay resumable. */ - close(): Promise; -} - -interface RefInfo { - name: string; - leafId: string | null; - run: null | { id: string; kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "cancelling" }; -} - -type RefResult = { ok: true; ref: AgentRef } | { ok: false; outcome: "rejected"; error: ErrorInfo }; - -interface SuspendedOperation { - ref: string; - kind: "run" | "compaction" | "navigation"; - id: string; - startedAt: string; - /** For runs: original prompt content (text and images), for display. */ - prompt?: (TextContent | ImageContent)[]; - /** The operation was cancelled pre-crash; resume() completes the abort. Undelivered - steer/follow-up payloads are returned here — the crash-path equivalent of - AbortResult — so a client can offer to requeue them. */ - cancelled?: { clearedSteer: AgentMessage[]; clearedFollowUp: AgentMessage[] }; - /** Identities the log references that current config cannot resolve. Non-empty: resume() resolves rejected. */ - missing: { tools: string[]; models: string[] }; -} -``` - -### Results, not exceptions - -Operation and queue methods never throw. Every call resolves with a result; a promise rejection is a bug, not an outcome. The invariant: a **durable outcome** corresponds exactly to an `operation_started`…`operation_finished` pair in the log (with matching events); `rejected` corresponds to a log that was not written; `faulted` to a log that can no longer be written. - -Result shape: `ok: true` carries the goods and nothing else; `ok: false` is a discriminated union of everything that went differently. Typical caller code is one check; code that cares switches on `outcome`. - -```ts -interface ErrorInfo { code: string; message: string } - -/** Failures shared by all methods. */ -type Failure = - | { ok: false; outcome: "rejected"; error: ErrorInfo } - // the call never became an operation — no runId exists anywhere, the log is untouched - | { ok: false; outcome: "faulted"; runId?: string; error: ErrorInfo }; - // appends stopped working. runId present: the operation started and restores as - // suspended after reopening. absent: the fault hit the acceptance append itself. - -// finalMessage is defined via the entry→message projection: the run's newest -// message entry that projects to an AssistantMessage. Custom entries project to -// nothing and custom messages project to user messages, so neither can be -// finalMessage by construction. Full transcript content is not duplicated in -// results — it is in the session (branch query scoped to the run) and was -// delivered via events. -type RunResult = - | { ok: true; runId: string; finalMessage: AssistantMessage } - | { ok: false; outcome: "aborted"; runId: string; finalMessage: AssistantMessage } // the aborted closure - | { ok: false; outcome: "failed"; runId: string; error: ErrorInfo; finalMessage?: AssistantMessage } - // finalMessage absent when the run failed before any assistant response (e.g. auto-compaction) - | Failure; - -type CompactionRunResult = - | { ok: true; runId: string; entry: CompactionEntry } - | { ok: false; outcome: "cancelled" | "aborted"; runId: string } // hook declined / user abort - | { ok: false; outcome: "failed"; runId: string; error: ErrorInfo } - | Failure; - -type NavigationRunResult = - | { ok: true; runId: string; newLeafId: string | null; summaryEntry?: BranchSummaryEntry } - | { ok: false; outcome: "cancelled" | "aborted"; runId: string } - | { ok: false; outcome: "failed"; runId: string; error: ErrorInfo } - | Failure; - -type QueueResult = - | { ok: true } // durably accepted in the log - | Failure; - -// Runs can never be "cancelled" (no hook vetoes an accepted run); -// kind discriminates which result shape applies. -type ResumeResult = - | ({ kind: "run" } & RunResult) - | ({ kind: "compaction" } & CompactionRunResult) - | ({ kind: "navigation" } & NavigationRunResult); -``` - -```ts -const result = await harness.prompt("..."); -if (!result.ok) { - showError(result); // switch on result.outcome for finer handling - return; -} -render(result.finalMessage); -``` - -Rejection reasons (`error.code`): `busy` (this ref), `suspended_pending`, `no_active_run` (steer/follow-up while the ref is idle), `nothing_to_resume`, `missing_identities` (resume with `missing`), `invalid_message` (does not convert to a user message), `unknown_skill`, `unknown_template`, `unknown_target`, `unknown_ref`, `ref_exists`, `invalid_ref` (createRef with unknown anchor or reserved name), `nothing_to_compact`, `closed`, `faulted` (harness already faulted when called). - -Why rejections are not `outcome: "failed"`: `failed` is a durable fact — the run happened, may have cost money, and its end is recorded. A rejection is the absence of any fact: no runId that appears anywhere, no events, nothing to resume. Callers also handle them differently — rejected means fix the call or wait; failed means the run is over, show the error. Faults are neither: the operation may still be resumable after the underlying cause is fixed, so claiming a terminal outcome would lie about the log. - -Semantics not visible in the signatures: - -- `prompt()`/`skill()`/`promptFromTemplate()` resolve when the run reaches its durable end; `finalMessage` carries the answer when one exists. A failed auto-compaction or exhausted retries resolve `outcome: "failed"` — no assistant message is fabricated for the return value (the transcript still gets an error assistant message where one naturally belongs, i.e. failed provider steps and aborts). Operations resolve `rejected` while the same ref has an operation active or suspended — a suspended operation must be resumed or aborted first, explicitly. Other refs are unaffected. -- `steer()`/`followUp()`/`nextRun()` resolve when the message is durably accepted, not when consumed. Consumption rules live with `QueueEnqueuedEntry` (section 5): steer/follow-up resolve within their run; next-run items are consumed by the same ref's next run, prepended to its initial messages. Once a run start exists, its initial messages are guaranteed to be appended — by recovery if necessary, even if the run is then cancelled — so accepted content is never silently dropped. A client that prefers to hold material itself can compose `prompt(messages[])` instead. -- Bash executions are not queue items: `!cmd` and `!!cmd` results are transcript appends via `session.appendMessage()` — immediate while idle, deferred writes mid-run. Both are persisted and displayed; `!!` sets `excludeFromContext`, and the default `convertToLlm` drops it from provider context. -- Persisted config (model, thinking level, active tools) is restored via branch point queries — `findEntryOnBranch("model_change")` etc. Compaction truncates message context, not config history: config entries behind a compaction barrier still count. (Today's `buildContext` loses them on reload; the old coding-agent got this right via full-path replay.) -- `abort()` on a suspended operation records the cancellation and reconciles without executing further provider or tool work. -- Retry lives in two layers: `streamOptions.maxRetries` covers provider-internal transport retries inside one request; `retry: RetryPolicy` is the harness policy across failed requests, with durable attempt counts. -- Deferred writes through `harness.session` apply in acceptance order at the next checkpoint. Both moments are observable and correlated by the provisioned entry id: acceptance fires a pending-write event (config getters also update immediately) and pending writes appear in the snapshot; application fires the normal entry events at the checkpoint with the same id (section 9). The raw storage is not reachable from the harness; writing to it directly while a harness is live is a contract violation. -- All calls on an already-faulted harness resolve `{ ok: false, outcome: "rejected", error: { code: "faulted" } }` — rejected, not faulted: the call itself never started anything. -- `close()`: rejects all further calls, signals in-flight provider/tool effects (no durable cancellation is recorded), waits for the append in progress to settle, discards late effect results, and releases the writer claim. An active run restores as suspended, same as after a crash; the log needs no shutdown record. -- One live `AgentHarness` per session; `create()` on a session with a live harness is a serving-layer error (SQLite rejects it, JSONL cannot detect it). - -## 8. Snapshots and subscription - -A UI needs current state plus every change after it, gap-free. That includes the transport gap: a server proxying a harness must get the snapshot to its client before any event reaches the wire. `watch()` buffers until the consumer arms delivery: - -```ts -const { snapshot, start, unsubscribe } = await ref.watch(); // harness.watch() = main's - -await send(client, { kind: "snapshot", snapshot }); // snapshot is on the wire -start((event) => send(client, event)); // flush buffer in order, then go live -``` - -`watch()` captures the snapshot and starts buffering atomically. `start(listener)` flushes the buffered events in order and switches to live delivery. Each event is delivered exactly once, in order — no sequence numbers, no registration race. `unsubscribe()` (before or after `start()`) drops the subscription and any buffer. - -`watch()` is **ref-scoped**: this ref's transcript, run state, queues, pending writes, and only this ref's events. A Slack-thread renderer sees its thread and nothing else; sibling refs are invisible (no refs inventory in a `RefSnapshot`). The session-wide observer is `harness.watchSession()`: its snapshot is the refs inventory — `RefInfo` per ref plus suspended details — with no transcripts, and its stream is the unfiltered firehose. A dashboard composes: `watchSession()` for the overview, `ref.watch()` per opened thread. - -```ts -interface RefSnapshot { - ref: string; - // Transcript: this ref's branch, oldest first (the context window plus - // its compaction entry; UIs page further history via session queries) - transcript: SessionTreeEntry[]; - leafId: string | null; - - run: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - status: "running" | "suspended" | "cancelling"; - startedAt: string; - /** status "suspended" only: what a client needs to offer resume/abort. - Same data create() returned — duplicated here because a remote UI - only ever sees the snapshot, not create()'s return value. */ - suspended?: { - prompt?: (TextContent | ImageContent)[]; - missing: { tools: string[]; models: string[] }; - cancelled?: { clearedSteer: AgentMessage[]; clearedFollowUp: AgentMessage[] }; - }; - /** Live progress, when mid-step. */ - streamingMessage?: AssistantMessage; - runningTools: { - /** Unique within the current tool batch; correlates with the tool-call block in the newest assistant message. */ - toolCallId: string; - toolName: string; - args: unknown; - /** Latest streamed partial result, when the tool reports updates. */ - partialResult?: AgentToolResult; - }[]; - retry?: { attempt: number; maxAttempts: number; nextAttemptAt: string }; - }; - - queues: { steer: AgentMessage[]; followUp: AgentMessage[]; nextRun: AgentMessage[] }; - pendingWrites: { id: string; entry: SessionTreeEntry }[]; - - faulted: boolean; // harness-wide; mirrored into every ref snapshot -} - -interface SessionSnapshot { - refs: (RefInfo & { - suspended?: SuspendedOperation; // resume/abort offer data, per ref - })[]; - faulted: boolean; -} -``` - -- Config (model, thinking level, active tools, resources, stream options) is not in the snapshot — getters are always current, and config events (section 9) tell the UI when to re-read. One source of truth. -- `streamingMessage` and `runningTools` let a UI attaching mid-step render immediately: the partial assistant message and each running tool's latest partial result, the state it would have accumulated from `message_update`/`tool_update` events. -- A `suspended` run in the snapshot is the UI's cue to offer resume/abort. -- Reconnect means calling `watch()` again. Against a living harness the new snapshot includes current live progress. Only process death loses it: a restored harness has no partial streams or running tools to report, and the snapshot shows the suspended run instead; the durable transcript is complete regardless. Surviving transport drops is the serving layer's job. -- A ref watcher receives the full event vocabulary of section 9, filtered to its ref; `watchSession()` and `harness.events.on(type, ...)` receive everything, unfiltered. `events.on` is live-only: no snapshot, no buffering. -- Watchers are independent, each with its own buffer and `start()` gate. -- A watcher that never calls `start()` buffers unboundedly; call `unsubscribe()` when abandoning one. - -## 9. Events - -One flat stream, shared by `harness.events.on(type, listener)` and `watch()`. - -Guarantees: - -- Passive: events cannot alter execution. A thrown listener exception (`watch()` or `events.on`) is caught and reported as a `handler_error` event plus telemetry — same channel as hook handler errors (section 10), never stdio. A listener that throws while handling a `handler_error` event is reported to telemetry only; the event is not re-emitted. -- Ordered: delivery follows process order, identically for streams and push listeners. -- Not persisted, not replayed; reconnect means a new `watch()`. -- Events reporting durable facts (`message_end`, `entry_added`, `run_end`, ...) fire only after the fact is committed; what an event announces is already queryable. -- Events report final effective values, after hook transformation. -- Event payloads are JSON-serializable and secret-free, so a server can proxy them to clients verbatim. Live objects (models, tools, resources, stream options) are referenced by name/id, never embedded. -- Every event carries `ref: string` — bluntly, all of them; omitted from the catalog listings for brevity. Every operational event additionally carries `runId`; step-scoped events carry `stepId`; recovered work carries `recovery: true`. - -### Catalog - -Derived from the existing `AgentEvent`, `AgentHarnessOwnEvent`, and coding-agent `AgentSessionEvent` vocabularies. Mutation hooks (`before_agent_start`, `context`, `tool_call`, ...) are not events anymore — they move to section 10. Fields shown without comment keep their existing meaning. - -```ts -// Run lifecycle ----------------------------------------------------------- - -interface RunStartEvent { - type: "run_start"; // was: agent_start - runId: string; -} - -interface RunResumeEvent { - type: "run_resume"; // new - runId: string; -} - -interface RunCancelEvent { - type: "run_cancel"; // was: abort - runId: string; - clearedSteer: AgentMessage[]; - clearedFollowUp: AgentMessage[]; -} - -interface RunEndEvent { - type: "run_end"; // was: agent_end + settled - runId: string; - outcome: "completed" | "aborted" | "failed"; - /** Same definition as RunResult.finalMessage. Transcript deltas were - already delivered via message/entry events; agent_end.messages is gone. */ - finalMessage?: AssistantMessage; - error?: ErrorInfo; -} - -interface FaultEvent { - type: "fault"; // new - code: string; - message: string; -} - -// was: coding-agent ExtensionError via onError. One channel for all -// extension-code failures — hook handlers and event listeners (section 10). -type HandlerErrorEvent = { - type: "handler_error"; - runId?: string; - error: string; - stack?: string; -} & ( - | { kind: "hook"; hook: string } // hook type - | { kind: "event"; event: string } // event type being delivered -); - -// Step and retry ---------------------------------------------------------- - -interface StepStartEvent { - type: "step_start"; // was: turn_start - runId: string; - stepId: string; -} - -interface StepEndEvent { - type: "step_end"; // was: turn_end (also the save-point moment) - runId: string; - stepId: string; - message: AssistantMessage; - toolResults: ToolResultMessage[]; -} - -// Retry — unifies harness retry_scheduled/retry_attempt_start/retry_finished -// and coding-agent auto_retry_* / summarization_retry_*. Normal requests emit -// nothing: the UI's busy state spans run_start..run_end (or the -// compaction/navigation brackets). Retry events only surface the exception. - -interface RetryScheduledEvent { - type: "retry_scheduled"; // a request failed, next attempt is pending - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -interface RetryStartEvent { - type: "retry_start"; // the scheduled attempt begins - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; -} - -interface RetryEndEvent { - type: "retry_end"; // retrying resolved: success, or final failure - runId: string; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; - success: boolean; - finalError?: string; -} - -// Messages and tools ------------------------------------------------------ - -interface MessageStartEvent { - type: "message_start"; - runId?: string; // absent for idle SessionTree writes - message: AgentMessage; -} - -interface MessageUpdateEvent { - type: "message_update"; // only emitted for assistant messages during streaming, as today. - runId: string; // AssistantMessageEvent already carries the partial message and - message: AgentMessage; // per-block deltas; we add nothing on top. - assistantMessageEvent: AssistantMessageEvent; -} - -interface MessageEndEvent { - type: "message_end"; - runId?: string; - message: AgentMessage; - entryId: string; // new: the committed session entry -} - -interface ToolStartEvent { - type: "tool_start"; // was: tool_execution_start - runId: string; - stepId: string; - toolCallId: string; - toolName: string; - args: unknown; // effective args, after before_tool -} - -interface ToolUpdateEvent { - type: "tool_update"; // was: tool_execution_update - runId: string; - stepId: string; - toolCallId: string; - toolName: string; - partialResult: AgentToolResult; -} - -interface ToolEndEvent { - type: "tool_end"; // was: tool_execution_end - runId: string; - stepId: string; - toolCallId: string; - toolName: string; - result: AgentToolResult; - isError: boolean; -} - -// Session and config ------------------------------------------------------ - -interface EntryAddedEvent { - type: "entry_added"; // generalizes coding-agent entry_appended - entry: SessionTreeEntry; // non-message entries: custom, label, name, config, compaction, summary -} - -interface WritePendingEvent { - type: "write_pending"; // new — deferred write durably accepted - runId: string; - entryId: string; // provisioned id; entry_added/message_end follows with the same id - entry: SessionTreeEntry; -} - -interface QueueUpdateEvent { - type: "queue_update"; // per ref — like every event it carries ref; - steer: AgentMessage[]; // these are that ref's queues - followUp: AgentMessage[]; - nextRun: AgentMessage[]; -} - -// Payloads identify the change compactly; clients needing full objects use the -// getters (locally or via their server). streamOptions carries no value: headers -// may hold secrets, transport may be a function. -type ConfigUpdateEvent = { - type: "config_update"; // was: model_update, thinking_level_update, tools_update, resources_update -} & ( - | { property: "model"; value: { provider: string; modelId: string }; previous: { provider: string; modelId: string } | null } - | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } - | { property: "activeTools"; value: string[]; previous: string[] } - | { property: "tools"; value: string[]; previous: string[] } // names - | { property: "resources"; value: { skills: string[]; promptTemplates: string[] } } // names - | { property: "streamOptions" } - | { property: "retryPolicy"; value: RetryPolicy } - | { property: "compactionSettings"; value: CompactionSettings } - | { property: "steeringMode"; value: QueueMode } - | { property: "followUpMode"; value: QueueMode } -); - -// Compaction and navigation ---------------------------------------------- - -interface CompactionStartEvent { - type: "compaction_start"; // from coding-agent compaction_start - runId: string; // the run for auto, the operation for manual - reason: "manual" | "threshold" | "overflow"; -} - -// End events mirror operation_finished.outcome and the result types — one -// vocabulary across log, results, and events. -interface CompactionEndEvent { - type: "compaction_end"; // was: session_compact + coding-agent compaction_end - runId: string; - reason: "manual" | "threshold" | "overflow"; - outcome: "completed" | "cancelled" | "aborted" | "failed"; - entry?: CompactionEntry; // outcome "completed" - fromHook: boolean; - error?: ErrorInfo; // outcome "failed" -} - -interface NavigationStartEvent { - type: "navigation_start"; // new — operation accepted, summary may generate - runId: string; // the navigation operation - targetId: string; -} - -interface NavigationEndEvent { - type: "navigation_end"; // was: session_tree — the leaf moves atomically here; - runId: string; // navigateTree() is the only cursor mutation - outcome: "completed" | "cancelled" | "aborted" | "failed"; - oldLeafId: string | null; - newLeafId: string | null; - summaryEntry?: BranchSummaryEntry; // outcome "completed", when summarize - error?: ErrorInfo; // outcome "failed" -} -``` - -### Nesting - -Start/end pairs bracket their operation; request, message, and tool events happen between them. What a consumer sees: - -```text -run_start - step_start - message_start - message_update* - message_end assistant committed - tool_start / tool_update* / tool_end per tool call - message_end toolResult committed, source order - step_end - compaction_start auto-compaction at a checkpoint, when needed - entry_added compaction entry committed - compaction_end - step_start ... step_end until no continuation -run_end -``` - -A UI's busy indicator spans the brackets: `run_start`..`run_end`, and for standalone operations `compaction_start`..`compaction_end` / `navigation_start`..`navigation_end`: - -```text -compaction_start reason: manual navigation_start - entry_added compaction entry entry_added summary entry -compaction_end navigation_end leaf moves here -``` - -A failed request inside any bracket emits `retry_scheduled`, then `retry_start` for the next attempt, then `retry_end` when retrying resolves — success or final failure. Requests that succeed first try emit no request-level events at all. - -All events additionally carry `recovery?: true` when emitted for recovered work. - -### Notes - -- Every message entering the session fires message events, regardless of source: agent loop, queues, `SessionTree` writes. `message_end` means committed, final content, usage known; UIs drop their streaming buffer on it. ToolResult messages get `message_start`/`message_end` in source order after `tool_end`. -- Messages get message events; every other entry gets `entry_added`. Both fire only after durable persistence. No entry commits without exactly one of the two firing. -- `runId`/`stepId` on message/tool events exist for correlation (telemetry spans, server-side log processing). A single-harness UI can ignore them; there is only one active run. -- `run_cancel` fires when cancellation is accepted; `run_end` (outcome `aborted`) fires after reconciliation completes. Between them the snapshot shows `status: "cancelling"`. -- Provider request internals (status, headers, payloads, timings) are not events; they belong to the observability channel and the `after_response` hook. - -### Old vs. new - -| old (loop / harness / coding-agent) | new | -|---|---| -| `agent_start` | `run_start` | -| `agent_end` | `run_end` (`outcome`, `error`, `finalMessage`; `agent_end.messages` dropped — deltas via message/entry events) | -| `settled` | `run_end` (settlement is part of finishing) | -| `abort` | `run_cancel` | -| `turn_start` / `turn_end` | `step_start` / `step_end` | -| `save_point` | dropped — internal; deferred-write application is visible via `entry_added`/`message_end` | -| `message_start` / `message_update` / `message_end` | same names and payloads; `message_end` gains `entryId` | -| `tool_execution_start` / `_update` / `_end` | `tool_start` / `tool_update` / `tool_end` | -| `after_provider_response` | dropped as event — `after_response` hook (section 10) and observability | -| `retry_scheduled`, `retry_attempt_start`, `retry_finished` (+ coding-agent `auto_retry_*`, `summarization_retry_*`) | `retry_scheduled` / `retry_start` / `retry_end`, unified via `purpose` | -| `queue_update` | `queue_update` (`nextTurn` field renamed `nextRun`) | -| `model_update`, `thinking_level_update`, `tools_update`, `resources_update` | `config_update` (discriminated union, all config properties) | -| coding-agent `entry_appended` | `entry_added` | -| — | `write_pending` (new: deferred write accepted) | -| `session_compact` (+ coding-agent `compaction_start`/`_end`) | `compaction_start` / `compaction_end` | -| `session_tree` | `navigation_start` / `navigation_end` | -| coding-agent `session_info_changed`, `thinking_level_changed` | `entry_added` / `config_update` | -| coding-agent `ExtensionError` via `onError` | `handler_error` | -| — | `run_resume`, `fault` (new) | -| `before_agent_start`, `context`, `before_provider_request`, `before_provider_payload`, `tool_call`, `tool_result`, `session_before_compact`, `session_before_tree` | not events — hooks (section 10) | - -## 10. Hooks - -Hooks are awaited control points: they can transform or block what the harness does next. Registration mirrors events: - -```ts -const off = harness.hooks.on("before_tool", async (event) => { - if (event.toolName === "bash") return { block: { reason: "not allowed" } }; -}); -``` - -Semantics, uniform across all hooks: - -- Registration is harness-global; every hook event carries `ref` (omitted from the shapes below), so a handler can scope itself. Whether per-ref registration is also wanted is an open question (section 17). -- Handlers run sequentially in registration order; each transformation handler sees the output of the previous one (same reduction rules as today's `emitHook` pipelines). -- A thrown hook handler exception does not fail the run. Following the old coding-agent's extension runner: the exception is caught per handler, reported, and the handler is treated as having returned nothing — remaining handlers still run. The exception: `before_tool` fails closed (see below). Already-committed mutations are never rolled back. -- Hook results that feed durable state are persisted before execution proceeds: `before_run` output lands in the operation-start harness entry, `before_tool` effective arguments in the `tool_started` harness entry. -- Events report post-hook effective values; observers never see pre-hook state. - -### Catalog - -```ts -// Run boundaries ---------------------------------------------------------- - -// was: before_agent_start. Once per run, before durable acceptance. -// Not re-run on retry or resume; its effective output is persisted. -// -// Durable run setup, unlike transform_context: returned messages become -// session entries after the prompt (skill preambles, injected context files); -// the effective system prompt is stored in the operation-start harness entry -// and used for the whole run, including resume. transform_context is -// per-request and ephemeral: it shapes what the provider sees, never what -// the session contains. -interface BeforeRunHook { - event: { - prompt: (TextContent | ImageContent)[]; - systemPrompt: string; - resources: AgentHarnessResources; - }; - result: { - /** Persisted as session entries after the prompt. */ - messages?: AgentMessage[]; - /** Persisted as systemPromptOverride in the operation-start harness entry; - fixed for the whole run. Without an override, the systemPrompt config - callback is evaluated per request instead. */ - systemPrompt?: string; - /** Opaque JSON, keyed by extension id, persisted in the operation-start - harness entry, handed back to before_resume (possibly on another - machine). For per-run state that would otherwise live in a closure: - external job ids, idempotency keys, mode flags. Keep it small. */ - resumeData?: JsonValue; - } | undefined; -} - -// New. On resume(), before any effect. Rebuilds process-local extension -// state; must be idempotent (a crash can rerun it). Cannot rewrite the -// accepted prompt or system prompt. -interface BeforeResumeHook { - event: { - runId: string; - kind: "run" | "compaction" | "navigation"; - /** Persisted effective before_run output. */ - prepared: { prompt: (TextContent | ImageContent)[]; systemPromptOverride?: string }; - resumeData?: JsonValue; - }; - result: void; -} - -// was: the actionable part of agent_end/settled. Runs when nothing is -// pending: no tool continuation, no queued messages. Work enqueued here -// (returned or via followUp()) continues the same run: no new -// run_start/run_end pair, same runId, more steps. run_end fires once, -// when this boundary passes with nothing pending. -interface BeforeRunEndHook { - event: { runId: string; messages: AgentMessage[] }; - result: { followUp?: string } | undefined; // or call steer()/followUp() directly -} - -// Request pipeline --------------------------------------------------------- - -// was: context. AgentMessage level, before convertToLlm. -// Pruning, injection, custom-message handling. -interface TransformContextHook { - event: { messages: AgentMessage[] }; - result: { messages: AgentMessage[] } | undefined; -} - -// was: before_provider_request. Provider-neutral request, after conversion. -interface BeforeRequestHook { - event: { - model: Model; - purpose: "step" | "compaction" | "branch_summary"; - attempt: number; - streamOptions: AgentHarnessStreamOptions; - }; - result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined; -} - -// was: before_provider_payload. Provider-specific wire payload. Last stop. -interface BeforePayloadHook { - event: { model: Model; payload: unknown }; - result: { payload: unknown } | undefined; -} - -// was: after_provider_response (observation) + message_end replacement -// (mutation). Runs after the stream finishes, before the assistant message -// is committed. The committed message is what events and the session see. -interface AfterResponseHook { - event: { - status: number; - headers: Record; - message: AssistantMessage; - }; - result: { message?: AssistantMessage } | undefined; // must keep role -} - -// Tools -------------------------------------------------------------------- - -// was: tool_call + loop beforeToolCall. After validation, before execution. -// Effective args are persisted in the tool_started harness entry. -interface BeforeToolHook { - event: { - toolCallId: string; - toolName: string; - args: Record; - }; - result: { - args?: Record; - block?: { reason: string }; - } | undefined; -} - -// was: tool_result + loop afterToolCall. Patch semantics field-by-field, -// no deep merge, as today. -interface AfterToolHook { - event: { - toolCallId: string; - toolName: string; - args: Record; - content: (TextContent | ImageContent)[]; - details: unknown; - isError: boolean; - usage?: Usage; - }; - result: { - content?: (TextContent | ImageContent)[]; - details?: unknown; - isError?: boolean; - usage?: Usage; - terminate?: boolean; - } | undefined; -} - -// Structural operations ---------------------------------------------------- - -// was: session_before_compact. Cancel, adjust, or supply the summary. -interface BeforeCompactionHook { - event: { - reason: "manual" | "threshold" | "overflow"; - preparation: CompactionPreparation; - customInstructions?: string; - }; - result: { cancel?: boolean; compaction?: CompactResult } | undefined; -} - -// was: session_before_tree. Cancel, adjust, or supply the branch summary. -interface BeforeNavigationHook { - event: { targetId: string; preparation: TreePreparation }; - result: { - cancel?: boolean; - summary?: { summary: string; details?: unknown; usage?: Usage }; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; - } | undefined; -} -``` - -### Failure semantics - -The old coding-agent catches every extension handler error, wraps it as `ExtensionError`, emits it to `onError` listeners, skips that handler's contribution, and continues; an extension bug never kills a run. (Only the old pi-agent harness `emitHook` rethrew into the run; that behavior is dropped.) - -The new harness keeps that model: - -- Default, all hooks: the throwing handler is skipped, a `handler_error` event is emitted, execution continues with the remaining handlers and the last effective value. -- `before_tool` fails closed: a throwing handler blocks the tool; an error tool result is committed and the run continues. Skipping a broken policy handler must not allow a tool it might have blocked. - -Reporting: the `handler_error` event (section 9) plus telemetry, one channel for hook handlers and event listeners alike. Recursion guard: a listener throwing while handling `handler_error` goes to telemetry only. - -### Replay across retry and resume - -| hook | fresh run | request retry | resume | output persisted | -|---|---|---|---|---| -| `before_run` | once | no | no | yes (operation start) | -| `before_resume` | no | no | yes, idempotent | no | -| `transform_context` | per request | yes | yes | no | -| `before_request` | per request | yes | yes | no | -| `before_payload` | per request | yes | yes | no | -| `after_response` | per response | per response | per response | via committed message | -| `before_tool` | per invocation | n/a | not for uncertain unsafe tools | yes (tool start) | -| `after_tool` | per executed result | n/a | on safe replay | via committed result | -| `before_compaction` | per compaction | no | not if result committed | via committed entry | -| `before_navigation` | per navigation | no | not if result committed | via committed entries | -| `before_run_end` | at every finish boundary | n/a | at the boundary resume reaches (may repeat across a crash) | via durable follow-ups | - -Hooks re-run only where the work itself re-runs; persisted effective outputs are never recomputed. - -### Old vs. new - -| old (harness / loop / coding-agent) | new | -|---|---| -| `before_agent_start` | `before_run` | -| `context` / loop `transformContext` | `transform_context` | -| `before_provider_request` | `before_request` | -| `before_provider_payload` | `before_payload` | -| `after_provider_response` + coding-agent `message_end` replacement | `after_response` | -| `tool_call` / loop `beforeToolCall` | `before_tool` | -| `tool_result` / loop `afterToolCall` | `after_tool` | -| `session_before_compact` | `before_compaction` | -| `session_before_tree` | `before_navigation` | -| `agent_end` / `settled` handlers that queue work | `before_run_end` | -| — | `before_resume` (new) | - -## 11. Traces - -How hooks, events, and durable appends interleave. Legend: - -```text -H hook (awaited) -E event (passive) -CS durable append: session entry (tree) -CH durable append: harness entry (orchestration, schemas: section 5) -X process dies -``` - -All traces except the last show a single-ref session; `ref: "main"` is omitted from records and events. - -### Simple run, one tool call - -```text - prompt("fix the bug") -H before_run may inject messages, transform system prompt -CH operation_started → E run_start op-1, prepared output persisted -CS message user → E message_start, message_end -E step_start step-1 -H transform_context -H before_request -H before_payload -CH generation_started before the billable request -E message_start assistant streaming begins -E message_update* -H after_response may replace the assistant message -CS message assistant [tool call] → E message_end -H before_tool may mutate args or block -CH tool_started effective args persisted -E tool_start -E tool_update* -H after_tool may patch the result -E tool_end -CS message toolResult → E message_start, message_end -E step_end - checkpoint: deferred writes, queues, compaction — nothing pending -E step_start step-2 -H transform_context / before_request / before_payload -CH generation_started -E message_start, message_update* -H after_response -CS message assistant "done" → E message_end -E step_end -H before_run_end returns nothing -CH operation_finished → E run_end outcome: completed -``` - -### Steering while a tool runs - -```text -E tool_start tool executing - steer("focus on tests") caller resolves at CH -CH queue_enqueued → E queue_update tree-neutral, mid-step is fine -E tool_end -CS message toolResult → E message_start, message_end -E step_end - checkpoint consumes steering -CS message user "focus on tests" → E message_start, message_end, queue_update -E step_start next request sees the steering message -``` - -Crash before `queue_enqueued`: steering was never accepted; the caller's promise never resolved. Crash after: recovery finds the queued item without its target message and delivers it. - -### Follow-up from before_run_end - -```text -E step_end -H before_run_end handler calls followUp("now write tests") -CH queue_enqueued → E queue_update durable before the hook returns - run continues — same runId, no new run_start -CS message user "now write tests" → E message_start, message_end -E step_start ... -H before_run_end runs again after those steps; returns nothing -CH operation_finished → E run_end exactly one, outcome: completed -``` - -### Request failure and retry - -```text -CH generation_started attempt 1 - provider fails (overloaded) -E retry_scheduled attempt 1, delayMs -E retry_start attempt 2 -H transform_context / before_request / before_payload re-run per attempt -CH generation_started attempt 2 — durable count survives restarts -E message_start, message_update* -E retry_end success: true -CS message assistant → E message_end -``` - -A crash during backoff: restore reads two attempt entries; resume continues with attempt 3. The count never resets. - -### Abort during a tool - -```text -E tool_start tool executing - abort() caller resolves after CH + signal -CH operation_cancelled → E run_cancel cleared steer/follow-up items returned - tool signalled; reconciliation in background -CS message toolResult → E message_start, message_end synthetic "interrupted", or real if it finished -CS message assistant → E message_start, message_end stopReason: aborted — provider-valid closure -CH operation_finished → E run_end outcome: aborted -``` - -### Auto-compaction between steps - -```text -E step_end - checkpoint: prospective context too big -E compaction_start reason: threshold -H before_compaction may cancel or supply the summary -CH generation_started purpose compaction — skipped if hook supplied -CS compaction entry → E entry_added -E compaction_end -E step_start next request uses compacted context -``` - -### Crash mid-tool, resume on another machine - -```text -CS message assistant [tool call] → E message_end -CH tool_started replay: never -X machine dies mid-execution - - — new machine — - AgentHarness.create(...) suspended: { kind: "run", ... } -H before_resume receives persisted resumeData; idempotent - resume() -E run_resume -CS message toolResult → E message_start, message_end synthetic "interrupted", not re-run; recovery: true -E step_end recovery: true -E step_start run continues normally from here -H transform_context / before_request / before_payload -CH generation_started -... -``` - -Not re-run: `before_run` (persisted), `before_tool` for the interrupted call (its decision is already durable in `tool_started`). A `replay: safe` tool would re-execute with the persisted effective args instead of getting a synthetic result. - -### Crash mid-request - -```text -CH generation_started attempt 1 -X dies mid-stream — partial tokens lost, never persisted - - — restore + resume — -E run_resume -H transform_context / before_request / before_payload -CH generation_started attempt 2: same step, durable attempt count -E message_start ... -``` - -### Navigation with summary, crash windows - -```text - navigateTree(target, { summarize: true }) -CH operation_started → E navigation_start destination + provisioned summary/leaf ids -H before_navigation may cancel (→ finished cancelled) or supply the summary -CH generation_started purpose branch_summary — skipped if hook supplied -CS leaf entry → E entry_added cursor moves — the atomic boundary -CS branch_summary entry → E entry_added -CH operation_finished → E navigation_end old/new leaf, summary -``` - -Crash before the leaf entry: old branch still active; resume completes the navigation. Crash after: destination active; resume appends only what is missing (summary, finish). Every prefix is a valid tree. - -### Deferred projecting write mid-request (append-only context) - -```text -CH generation_started request in flight; context tip is user U1 - handler calls session.appendMessage(M) a custom message that projects into context -CH write_deferred → E write_pending durable acceptance; M is not in the tree yet -CS message assistant A1 → E message_end provider cached prefix [.., U1, A1] -E step_end - checkpoint applies deferred writes -CS message M → E message_start, message_end tail append, after A1 -E step_start next context [.., U1, A1, M, ...] — prefix intact -``` - -Appending M immediately would have produced [.., U1, M, A1] — a provider-valid sequence that silently invalidates the KV cache from M onward, and a transcript claiming A1 saw M when it did not. The checkpoint prevents both (append-only context, section 5). A custom *entry* without a projector needs none of this — it projects to nothing and could not affect the provider view either way; it still defers, uniformly. - -### Two refs, interleaved log - -```text - main: prompt("fix the bug") slack:t1: prompt("summarize this thread") -CH operation_started ref=main -CH operation_started ref=slack:t1 -CH generation_started ref=main attempt 1 for main's cycle -CH generation_started ref=slack:t1 attempt 1 for t1's cycle — no interference -CS message assistant chained on main's branch (no ref field — -CS message assistant chained on t1's branch membership by parentId) -CH operation_finished ref=slack:t1 → E run_end (ref slack:t1) -CH operation_finished ref=main → E run_end (ref main) -``` - -Records interleave freely in the log; every reduction filters by ref first, and within one ref the single-writer positional rules of section 5 hold unchanged. The two runs share nothing but the writer and the tree prefix behind their anchors. - -## 12. Recovery - -### Restore - -`AgentHarness.create()` reduces the log to harness state. It performs no provider or tool effects and appends nothing. The reduction runs once per ref — refs restore independently; the flow below is per ref, with all reads filtered by ref: - -```mermaid -flowchart TD - O[open session, enumerate refs] --> A{ref has unmatched
operation_started?} - A -->|no| I[ref Idle] - I --> NR[collect ref's pending next-run items] - A -->|yes| T[read ref's tail: seq > opStart] - T --> V{tail valid?} - V -->|no| X[corruption error] - V -->|yes| M[resolve model/tool identities] - M --> S[ref Suspended] -``` - -Restore never reads the full log. The invariants of section 5 are enforced in two places: at append time by storage constraints (id uniqueness, seq monotonicity — violations cannot enter the log), and at restore time only over what restore reads. JSONL reads the whole file at open anyway and validates everything as a side effect; SQLite does not have to. - -Precisely: - -1. **Single-operation check.** Per ref: `count(operation_started) − count(operation_finished) ≤ 1` — indexed aggregates grouped by ref. More than one unmatched operation on one ref: corruption error, no automatic repair. -2. **Locate the active operation.** The ref's latest `operation_started` without matching `operation_finished` (index seek). -3. **Idle path.** No active operation: the ref is idle. Pending next-run items = the ref's `queue_enqueued(nextRun)` entries after its last run-kind `operation_started` whose target id has no entry (point lookups). Done. -4. **Suspended path.** Read the ref's tail (`seq > opStart.seq`, filtered by ref, range scan) and reduce: - - cancellation: is `operation_cancelled` present - - attempts: count of tail attempts after the newest session entry of this ref's chain (parentId membership; the current request cycle) - - tools: `tool_started` entries and, per entry, whether `resultEntryId` exists - - queues: `queue_enqueued` items whose target id has no entry; steer/follow-up dead if cancelled - - deferred writes: `write_deferred` entries whose target id has no entry, in acceptance order - - initial messages: which provisioned ids from the operation start exist - - Tail-scoped validation happens here: attempt numbers consecutive, tool identities unique, referenced assistant entries present, provisioned targets consistent. A violation is a corruption error. -5. **Resolve identities.** Persisted model references and tool names from the operation start and tail against current config → that entry's `missing`. -6. Return `{ harness, suspended }` — one `SuspendedOperation` per ref that has one. Nothing has executed. - -Old sessions have no harness entries and restore idle, even when the transcript ends in a state that looks continuable. - -### Harness state - -One in-memory record is the harness's working state, live and restored alike. The invariant: **`state` always equals the reduction of the log.** During normal execution the harness never queries the log — every accepted append updates `state` in the same serialized section that performed the append. Restore recomputes the identical record from the log; that is all restore is. - -Update rules per append, applied to the appending ref (known directly while live; recovered via chain membership during restore): `generation_started` → increment `requestAttempts`; any session entry → reset that ref's `requestAttempts` to 0; assistant message → set `toolBatch` (or clear it when call-free); tool result → mark its call resolved; `queue_enqueued` → push to the matching pending list; a queue target or deferred-write target landing → remove the pending item; `write_deferred` → push to `pendingWrites`; `operation_cancelled` → set `cancelled`; `operation_started`/`operation_finished` → set/clear the ref's `operation`. - -```ts -interface HarnessState { - /** One slot per ref; the structure below is per ref. */ - refs: Map; -} - -interface RefState { - leafId: string | null; - - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - sourceLeafId: string | null; - intent: OperationStartedEntry["intent"]; - cancelled: boolean; // operation_cancelled present - - /** Provisioned initial messages whose ids have no entry, in order. Runs only. */ - missingInitialMessages: ProvisionedMessage[]; - - /** Attempts already made for the current request cycle — there is never - more than one live. Incremented on generation_started; reset to 0 - whenever a session entry commits (the transcript advanced, the next - request asks a different question). Restore seeds it during the tail - reduction: same two rules applied left to right. */ - requestAttempts: number; - - /** This run's newest assistant message with tool calls, if any, with per-call state. */ - toolBatch: null | { - assistantEntryId: string; - calls: { - toolIndex: number; - toolCallId: string; - started: ToolStartedEntry | null; - resultExists: boolean; - }[]; - }; - - /** Accepted, unconsumed, in acceptance order. Dead (returned via suspended.cancelled) if cancelled. */ - pendingSteer: ProvisionedMessage[]; - pendingFollowUp: ProvisionedMessage[]; - /** Accepted, unapplied, in acceptance order. Survive cancellation. */ - pendingWrites: ProvisionedEntry[]; - - /** Structural targets: does an entry with the provisioned id exist? */ - targets: { result?: boolean; leaf?: boolean; summary?: boolean; label?: boolean }; - }; - - /** This ref's queue_enqueued(nextRun) after its last run-kind operation start, targets absent. */ - pendingNextRun: ProvisionedMessage[]; -} -``` - -### Resume: dispatch - -The code below is the specification. It runs in the context of one ref: `resume()` is an `AgentRef` method, `state` is that ref's `RefState`, `op` its operation; different refs' procedures run concurrently, serialized only through the log append path. Two error classes carry control flow: `RunFailed` (orderly durable failure — converted to `operation_finished(failed)`) and `AppendFailed` (storage broke — converted to the faulted state; no finish entry is possible). Neither escapes to the API caller. - -```ts -async function resume(): Promise { // per ref - if (suspended.missing.tools.length || suspended.missing.models.length) { - return { ok: false, outcome: "rejected", error: { code: "missing_identities", message: ... } }; - } - events.emit({ type: "run_resume", runId: op.id, recovery: true }); - switch (op.kind) { - case "run": return { kind: "run", ...await runProcedure() }; - case "compaction": return { kind: "compaction", ...await compactionProcedure() }; - case "navigation": return { kind: "navigation", ...await navigationProcedure() }; - } -} -``` - -Live and resume paths run the *same* procedures — `prompt()` calls `runProcedure()` after appending `operation_started`, `resume()` calls it with the operation already in the log. That includes `abort()`: a resuming operation is just a running operation, so abort applies normally (cancellation appended, effects signalled, `cancellationPath()` reconciles) and `resume()` resolves `outcome: "aborted"`. The helper that makes re-entry safe everywhere: - -```ts -/** Append a provisioned session entry unless an entry with its id already exists. */ -async function appendIfMissing(target: ProvisionedEntry): Promise { - if (!(await session.getEntry(target.id))) { - await appendSessionEntry(target); // → message/entry events, recovery-flagged during resume - } -} -``` - -Watchers across resume — snapshot on attach, then events, all with `recovery: true`: - -| case | snapshot shows | events during resume() | -|---|---|---| -| run, mid-step | `run.status: "suspended"` | `run_resume` → message events for reconciliation appends (initial messages, synthetic results) → normal step/message/tool events → `run_end(outcome)` | -| run, cancelled pre-crash | `"suspended"`; payloads in `suspended.cancelled` | `run_resume` → message events for synthetics and aborted closure → `run_end(aborted)`. No second `run_cancel` — acceptance was announced pre-crash | -| compaction | `run.kind: "compaction"`, `"suspended"` | `run_resume` → `compaction_start` re-emitted so brackets balance → `entry_added` → `compaction_end` | -| navigation | same pattern | `run_resume` → `navigation_start` re-emitted → `entry_added` per leaf/summary/label → `navigation_end` | - -Resumed structural operations re-emit their start event (`recovery: true`) so a UI attaching mid-resume always sees balanced start/end pairs. - -Every append recovery makes is an ordinary append: it emits the ordinary events (section 9 rules — messages get message events, other entries get `entry_added`, finishes get `run_end`/`compaction_end`/`navigation_end`), each with `recovery: true`. - -### Run procedure - -```ts -async function runProcedure(): Promise { - try { - // Initial messages — unconditional, even when cancelled below: - // accepted content is never dropped. - for (const msg of op.intent.initialMessages) await appendIfMissing(msg); - - if (state.cancelled) return await cancellationPath(); - - if (state.toolBatch?.calls.some((c) => !c.resultExists)) { - await reconcileToolBatch(state.toolBatch); - } - - return await driverLoop(); - } catch (err) { - return await handleRunError(err); - } -} - -async function handleRunError(err: unknown): Promise { - if (err instanceof RunFailed) { - await appendHarnessEntry({ type: "operation_finished", outcome: "failed", error: err.info }); - // newestAssistantProjection() is run-scoped: newest message entry appended - // after operation_started that projects to an AssistantMessage. May be undefined. - const finalMessage = newestAssistantProjection(); - events.emit({ type: "run_end", runId: op.id, outcome: "failed", error: err.info, finalMessage }); - return { ok: false, outcome: "failed", runId: op.id, error: err.info, finalMessage }; - } - enterFaultedState(err); // AppendFailed, or a bug — either way we cannot safely continue - events.emit({ type: "fault", code: ..., message: ... }); - return { ok: false, outcome: "faulted", runId: op.id, error: errorInfo(err) }; -} -``` - -There is no separate "crashed mid-generation" case: an interrupted generation means its result entry does not exist, and `driverLoop()` starts the next generation with the durable attempt count deciding retry versus `RunFailed`. Same for a crash mid-auto-compaction: the loop re-evaluates the checkpoint. - -### The driver loop - -The same loop drives fresh and resumed runs. `appendIfMissing` everywhere is what makes re-entry after a mid-loop crash safe: - -```ts -async function driverLoop(): Promise { - while (true) { - // ── checkpoint ───────────────────────────────────────────── - for (const write of state.pendingWrites) await appendIfMissing(write.target); - for (const msg of takeQueued(state.pendingSteer, config.steeringMode)) { - await appendIfMissing(msg); - } - if (await contextOverLimit()) await autoCompact(); // may throw RunFailed - - // ── step, while the model owes a response ───────────────────────── - if (needsAssistantResponse()) { // newest run message is user/steering/toolResult - const assistant = await requestAssistant(); // may throw RunFailed - if (hasToolCalls(assistant)) await executeToolBatch(assistant); - continue; // every step ends in a fresh checkpoint - } - - // ── follow-ups ────────────────────────────────────────────── - const followUps = takeQueued(state.pendingFollowUp, config.followUpMode); - if (followUps.length > 0) { - for (const msg of followUps) await appendIfMissing(msg); - continue; - } - - // ── finish boundary ──────────────────────────────────────── - const result = await hooks.run("before_run_end", { runId: op.id, messages: runMessages() }); // this run's messages - if (result?.followUp) await harness.followUp(result.followUp); - if (hasPendingWork()) continue; // hook enqueued something → keep going - - await appendHarnessEntry({ type: "operation_finished", outcome: "completed" }); - const finalMessage = newestAssistantProjection(); - events.emit({ type: "run_end", runId: op.id, outcome: "completed", finalMessage }); - return { ok: true, runId: op.id, finalMessage }; - } -} - -async function requestAssistant(): Promise { - while (true) { - // Retrying the same position grows the count; any committed session entry - // resets it. Seeded from the log at restore, so the bound survives - // restarts (see HarnessState.requestAttempts). - const attempt = state.requestAttempts + 1; - if (attempt > config.retry.maxAttempts) { - await appendSessionEntry(errorAssistantMessage()); // transcript records the give-up - throw new RunFailed({ code: "retries_exhausted", message: ... }); - } - - // Effective system prompt, per request: the persisted before_run override - // if set, else the systemPrompt config callback evaluated fresh — it sees - // current active tools, preserving the old mid-run rebuild behavior. - const systemPrompt = op.intent.systemPromptOverride ?? await evalSystemPromptConfig(); - const context = await hooks.run("transform_context", { messages: await contextMessages() }); - const options = await hooks.run("before_request", { model, purpose: "step", attempt, streamOptions }); - // before_payload runs inside the provider call, on the wire payload - - await appendHarnessEntry({ type: "generation_started", purpose: "step", attempt, ... }); - try { - const response = await streamRequest(context, options); // → message_start/update events - const final = (await hooks.run("after_response", response))?.message ?? response.message; - await appendSessionEntry(assistantEntry(final)); // → message_end - return final; - } catch (err) { - if (!isRetryable(err)) { - await appendSessionEntry(errorAssistantMessage(err)); - throw new RunFailed(errorInfo(err)); - } - events.emit({ type: "retry_scheduled", attempt, delayMs: backoff(attempt), ... }); - await sleep(backoff(attempt)); - events.emit({ type: "retry_start", attempt: attempt + 1, ... }); - } - } -} - -async function autoCompact(): Promise { - events.emit({ type: "compaction_start", runId: op.id, reason: "threshold" }); - const prep = prepareCompaction(await contextEntries()); - const hook = await hooks.run("before_compaction", { reason: "threshold", preparation: prep }); - if (hook?.cancel) { - events.emit({ type: "compaction_end", runId: op.id, outcome: "cancelled", ... }); - return; // run continues; overflow, if it comes, fails the step - } - const result = hook?.compaction ?? await generateBounded("compaction", prep); // may throw RunFailed - await appendSessionEntry(compactionEntry(result)); // → entry_added - events.emit({ type: "compaction_end", runId: op.id, outcome: "completed", entry, fromHook: !!hook?.compaction, ... }); -} -``` - -One undecidable case, decided by policy: the log ends at "final assistant committed, nothing pending". Resume enters `driverLoop()` and reaches the finish boundary, so `before_run_end` runs — whether it already ran before the crash cannot be known. Policy: the hook fires at every finish boundary actually reached, including this one; a handler may see the same boundary twice across a crash. Handlers that must not double-fire keep their own durable marker (resumeData, custom entries). This is boundary re-evaluation, not replay of interrupted handler code — the non-goal in section 1 stands. - -### Tool batch reconciliation - -```ts -async function reconcileToolBatch(batch: ToolBatch): Promise { - for (const call of batch.calls) { // assistant source order - if (call.resultExists) continue; // committed, incl. hook-patched content - - if (call.started) { - // tool_started exists ⇒ before_tool and validation already ran and - // cleared this invocation; effective args and the not-blocked decision - // are the durable outcome. - if (call.started.replay === "safe") { - const result = await executeTool(call.started.toolName, call.started.effectiveArgs); - const patched = await hooks.run("after_tool", { ...call, ...result }); - await appendIfMissing(toolResultEntry(call.started.resultEntryId, patched ?? result)); - } else { - // replay "never": the effect may or may not have happened; running it - // again is worse than admitting that. No hooks run. - await appendIfMissing(syntheticToolResult(call.started.resultEntryId, "interrupted")); - } - } else { - // never began: full normal path - await executeToolCallNormally(call); // validate → before_tool → block? error result - // : tool_started → execute → after_tool → result - } - } - // The step then ends normally; the model sees every call answered. -} -``` - -Synthetic "aborted" results exist only in `cancellationPath()`, where pending calls are not executed because the run is ending. - -### Cancellation path - -Reached live after `abort()`, or on resume when `operation_cancelled` exists without `operation_finished` (the process died between `abort()` and the end of reconciliation): - -```ts -async function cancellationPath(): Promise { - // initial messages were already appended by runProcedure() - - for (const call of state.toolBatch?.calls ?? []) { // source order - if (call.resultExists) continue; - await appendIfMissing(syntheticToolResult( - call.started ? call.started.resultEntryId : provisionResultId(call), - call.started ? "interrupted" : "aborted", // post-crash there is nothing to salvage - )); - } - - for (const write of state.pendingWrites) await appendIfMissing(write.target); // facts survive abort - - // The transcript always records how the run ended — including the - // no-assistant-yet case (cancelled during the first request). - if (!newestRunAssistantIsAborted()) { - await appendSessionEntry(abortedClosureMessage()); - } - - await appendHarnessEntry({ type: "operation_finished", outcome: "aborted" }); - const finalMessage = newestAssistantProjection(); // the aborted closure - events.emit({ type: "run_end", runId: op.id, outcome: "aborted", finalMessage }); - return { ok: false, outcome: "aborted", runId: op.id, finalMessage }; -} -``` - -Steer/follow-up items die undelivered; their payloads were surfaced via `AbortResult` (live) or `suspended.cancelled` (restore) so a client can requeue them. Next-run items survive. - -### Compaction procedure - -One procedure for live `compact()` and resume: live appends `operation_started` first; resume enters with it already in the log and skips whatever the targets say is done. `before_compaction` runs after `operation_started`, always — same contract on both paths, and a persisted operation always reaches a durable end: - -```ts -async function compactionProcedure(): Promise { - try { - // op.persisted is runtime-only (not in HarnessState): false when the live - // call enters before its operation_started entry exists, true on resume. - if (!op.persisted) await appendOperationStarted(); // live entry point - events.emit({ type: "compaction_start", runId: op.id, reason }); // re-emitted on resume - - if (!state.targets.result) { - const hook = await hooks.run("before_compaction", { reason, preparation, customInstructions }); - if (hook?.cancel) { - await appendHarnessEntry({ type: "operation_finished", outcome: "cancelled" }); - events.emit({ type: "compaction_end", runId: op.id, outcome: "cancelled", ... }); - return { ok: false, outcome: "cancelled", runId: op.id }; - } - const result = hook?.compaction - ?? await generateBounded("compaction", preparation); // may throw RunFailed - await appendSessionEntry(compactionEntry(op.intent.resultEntryId, result)); - } - - await appendHarnessEntry({ type: "operation_finished", outcome: "completed" }); - events.emit({ type: "compaction_end", runId: op.id, outcome: "completed", entry, ... }); - return { ok: true, runId: op.id, entry }; - } catch (err) { - return await handleStructuralError(err); - } -} - -// Structural twin of handleRunError. The failed path still emits the end -// event so the start/end bracket balances for every outcome. -async function handleStructuralError(err: unknown) { - const endEvent = op.kind === "compaction" ? "compaction_end" : "navigation_end"; - if (err instanceof RunFailed) { - await appendHarnessEntry({ type: "operation_finished", outcome: "failed", error: err.info }); - events.emit({ type: endEvent, runId: op.id, outcome: "failed", error: err.info, ... }); - return { ok: false, outcome: "failed", runId: op.id, error: err.info }; - } - enterFaultedState(err); // AppendFailed, or a bug - events.emit({ type: "fault", code: ..., message: ... }); - return { ok: false, outcome: "faulted", runId: op.id, error: errorInfo(err) }; -} -``` - -### Navigation procedure - -Same shape; append order is attempt → leaf → summary → label → finish. `before_navigation` runs after `operation_started`, always: - -```ts -async function navigationProcedure(): Promise { - try { - if (!op.persisted) await appendOperationStarted(); - events.emit({ type: "navigation_start", runId: op.id, targetId }); // re-emitted on resume - const { intent } = op; - let summary: SummaryContent | undefined; - - if (!state.targets.leaf) { - // the navigation has not happened yet - const hook = await hooks.run("before_navigation", { targetId: intent.targetId, preparation }); - if (hook?.cancel) { - await appendHarnessEntry({ type: "operation_finished", outcome: "cancelled" }); - events.emit({ type: "navigation_end", runId: op.id, outcome: "cancelled", ... }); - return { ok: false, outcome: "cancelled", runId: op.id }; - } - summary = hook?.summary - ?? (intent.summarize ? await generateBounded("branch_summary", ...) : undefined); - await appendSessionEntry(leafEntry(intent.leafEntryId, intent.destinationLeafId)); - // ↑ the cursor moves here, atomically - } - - if (intent.summarize && !state.targets.summary) { - // leaf moved pre-crash without a summary → regenerate (attempt-bounded) - summary ??= await generateBounded("branch_summary", ...); - await appendIfMissing(summaryEntry(intent.summaryEntryId, summary)); - } - if (intent.label && !state.targets.label) { - await appendIfMissing(labelEntry(intent.labelEntryId, intent.label)); - } - - await appendHarnessEntry({ type: "operation_finished", outcome: "completed" }); - events.emit({ type: "navigation_end", runId: op.id, outcome: "completed", oldLeafId, newLeafId, summaryEntry, ... }); - return { ok: true, runId: op.id, newLeafId, summaryEntry }; - } catch (err) { - return await handleStructuralError(err); - } -} -``` - -The transient state — destination active without its summary (crash between the leaf and summary appends) — is a valid tree; recovery closes the gap. - -### Guarantees - -- **Idempotent.** A crash during recovery leaves a longer valid prefix; the next restore continues from it. Every recovery append is an entry normal execution would have written — there is no recovery-only entry type — and every append skips targets that already exist. -- **Effect-safe.** Recovery never repeats an effect whose outcome is unknown: unsafe tools get synthetic results, lost provider responses get new attempts under the durable count (possibly double-billed — the count still bounds spend). -- **Hook-honest.** Hooks re-run only where the work itself re-runs (section 10 replay table). Interrupted handlers are not replayed; their accepted durable outputs are already in the log. -- **Observable.** Ordinary events with `recovery: true`; `run_resume` fires once per `resume()`; the first snapshot after restore shows the suspended operation. - -## 13. Forks - -One copy primitive; the scope option decides how much comes along: - -```ts -interface SessionCreateOptions { - id?: string; - /** New. Link a fresh session to a parent, e.g. a subagent's session to the - session whose tool call spawned it. Same linkage fork sets automatically. */ - parentSessionId?: string; -} - -type SessionForkOptions = - /** Existing behavior: the selected branch only — root to the fork point. - Sibling branches are not copied. Default: the source main's branch. */ - | { scope?: "branch"; entryId?: string; position?: "before" | "at" } - /** New: the entire tree — all session entries, every branch, leaf preserved. */ - | { scope: "tree" }; - -interface SessionRepo { - ... - create(options: TCreateOptions): Promise; - fork(source, options: SessionForkOptions & TCreateOptions): Promise; -} -``` - -Rules, both scopes: - -- **Session entries only, zero harness entries.** Orchestration history describes the source's execution. A fork starts idle: no operation, no queues, no pending writes, `suspended: []`. -- **Refs:** `scope: "branch"` → the new session has only `main`, at the fork point. `scope: "tree"` → **TBD**: current proposal copies all refs as-is; alternatives (only `main`, positioned at the source's `main`) unresolved. Labels and the session name (global records, section 5) copy with `scope: "tree"`; with `scope: "branch"`, labels copy iff their target entry was copied, the name always. -- **The source is untouched.** Copying while the source has an active run reads the committed prefix; the run stays active in the source and is never inherited. Forking a running session is safe in both scopes: a fork point may be **any message entry** (relaxed from the old user-message-only validation — platform threads root at arbitrary messages), and a copy whose tip sits mid-tool-batch is still promptable — pi-ai's `transformMessages` inserts synthetic empty results for orphaned tool calls at request build time, so no acceptance check or history rewriting is needed. -- **Linkage** via metadata (`parentSessionPath` in JSONL, the equivalent in SQLite), set automatically by `fork()` and explicitly by `create({ parentSessionId })` — the basis for session-group operations like export bundles and subagent parent/child tracking. A subagent tool creates its child session this way and returns the child's id in its tool result. Durability needs no schema support: the tool can derive the child id deterministically from its invocation (`execute(toolCallId, ...)` — e.g. `f(parentSessionId, toolCallId)`), so a `replay: "safe"` re-execution derives the same id and reattaches instead of spawning a twin; and because the child records `parentSessionId` at creation, children remain discoverable from the parent even when a crash swallowed the tool result. -- Persisted config derives from the copied tree via the usual branch point queries; `main` sits at the fork point (`scope: "branch"`) or the source's `main` leaf (`scope: "tree"`). -- **Threads are refs first.** A platform thread sharing one source of truth with its channel is a ref in the same session (section 6), not a fork. Fork when a *separate* session is wanted: subagents, exports, clones. Whether a thread becomes a ref, a fork, or a fresh session with platform backlog as prompt-time context is application policy; all three are supported. - -## 14. Storage backends - -Backends implement append + read + the finder queries for one session. They know nothing about operations, queues, or recovery — the harness entry payloads are opaque to them apart from the columns they index. - -Contract, all backends: - -- One total append order (`seq`) across session and harness entries. Harness entries and leaf records carry `ref`; session entries do not (membership derives from parent linkage). -- An append is durable when its promise resolves; events fire after. -- Entry ids are unique per session, enforced at append. -- Reads return immutable snapshots; callers cannot mutate stored state. -- One writer per *session*, enforced by the serving layer; SQLite additionally rejects concurrent writers itself. This is per session, not per backend: one SQLite database is a repo hosting many sessions, all writable concurrently — each through its own single live harness. Same for a directory of JSONL files. - -### JSONL - -One file: metadata header line, then one JSON object per line in append order. Format v4 adds harness entries, interleaved exactly as appended. - -- Open reads the whole file into memory; all queries (finders, chain walks, log reads, restore validation) run against that in-memory state. Appends serialize through the instance and write one line each. v4: harness entries and leaf records carry `ref`; label/name records are global (no `parentId`); absent fields in v3 files read as `main`. -- **Torn tail:** a malformed *final* line is a crash artifact — the append that died mid-write. Open truncates it and continues; the entry it would have contained was never acknowledged, so nothing is lost. A malformed line anywhere else is corruption: open rejects. -- **Uncertain acknowledgement** (process died between write and ack): the caller that died never observed the resolve. On reopen the line either parses (committed) or is the torn tail (not). No ambiguity survives. -- **v3 files** load unchanged: zero harness entries, restore idle. `custom_message` entries convert to custom messages on read. The format version lives in the header line, so a v3 file cannot simply receive v4 appends: before the first append, the file is rewritten once with a v4 header (write temp, rename — atomic on the same filesystem), entries byte-identical. Read-only opens never rewrite. -- Durability is process-crash level: a resolved `appendFile` call. No fsync/power-loss promise; if that is ever required it becomes an explicit capability, not an implied one. - -### In-memory - -Chronological record list plus an id index. Append validates, clones, then commits to state; reads clone out. Reference implementation for the contract — the parity test suite runs against it first. - -### SQLite - -Session entries stay in `session_entries`. Harness entries get their own table — they never participate in branch materialization, stats, or context, so mixing them into `session_entries` would force every existing query to exclude them: - -```sql -CREATE TABLE harness_entries ( - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, -- shared sequence with session_entries - id TEXT NOT NULL, - type TEXT NOT NULL, -- operation_started, generation_started, ... - ref TEXT NOT NULL, -- partition key for per-ref reduction - run_id TEXT NOT NULL, - op_kind TEXT, -- operation_started only: run | compaction | navigation - timestamp TEXT NOT NULL, - payload TEXT NOT NULL, - PRIMARY KEY (session_id, seq) -); -CREATE UNIQUE INDEX idx_harness_session_id ON harness_entries(session_id, id); -CREATE INDEX idx_harness_ref_type_seq ON harness_entries(session_id, ref, type, seq); -CREATE INDEX idx_harness_ref_kind_seq ON harness_entries(session_id, ref, type, op_kind, seq); -``` - -- `seq` comes from the existing per-session sequence, allocated across both tables, so `getLog()` is a merge of two range scans by `seq`. `session_entries` is unchanged — no ref column; the tail reduction resolves chain membership in memory. -- Restore's queries are all index seeks + bounded scans, per ref: the ref's latest `operation_started`/`operation_finished` via `idx_harness_ref_type_seq`, its last run-kind start via `idx_harness_ref_kind_seq`, the tail via the primary key filtered by ref. -- **Per-ref leaf state:** the single active-leaf projection becomes a refs table — `(session_id, ref, leaf_id)` — updated by leaf records. Old databases migrate to a single `main` row. -- **`branch_entries`** is materialized to root — no longer truncated at the newest compaction — keyed **per ref** (each ref's queries walk its own path: extended incrementally by that ref's appends, rebuilt only when that ref navigates), and gains denormalized `entry_type` / `custom_type` columns with an index on `(session_id, ref, entry_type, entry_seq)`. Every branch finder is an index seek plus a range scan in either direction; compaction is a query-time `stopAtType`, not a materialization boundary. Refs whose paths share a prefix duplicate those rows — bounded cache cost, not log cost. Branch-switch rebuild stays the rare expensive case, optimizable later by diffing against the previous path. -- **Each append is one transaction:** allocate seq → insert → update projections (the ref's leaf, its `branch_entries`, materialized stats/labels for session entries; nothing for harness entries) → commit → then events. In-memory caches roll back with the transaction. -- Labels and session name live in their existing projection tables; their records are no longer tree entries (section 5), which changes nothing about how they are stored, only that they never enter `branch_entries`. -- **Writer claim:** a lease row per session (owner id + heartbeat). `create()` on a session with a live claim fails; a stale claim (crashed owner) is taken over. This is the "SQLite rejects concurrent harnesses" enforcement from section 1. -- **Fork** copies session entries only — the selected branch (`scope: "branch"`) or all (`scope: "tree"`) — and never touches `harness_entries`. -- Malformed rows are never silently skipped: a row that fails decoding in any durable read path is a corruption error. (The current implementation drops such rows in `findEntries`; that behavior is a bug under this design.) -- `PRAGMA journal_mode=WAL`, `synchronous=FULL` stays the durability policy. - -### Append failure - -Any backend append failure faults the harness (section 4): the instance stops, in-flight calls resolve `faulted`, and the log remains a valid prefix. For SQLite, a failed transaction rolls back cleanly; for JSONL, a partial line becomes the torn tail the next open repairs. - -## 15. Telemetry - -The third channel, next to events (observe from outside) and hooks (control): in-process diagnostics for logging and tracing. Vendor-neutral — pi emits stable, structured span events; external subscribers convert them to OTel spans, Sentry spans, logs, or metrics. Core packages never import OTel, Sentry, or Node-only APIs. (Origin: `packages/agent/docs/observability.md`; this section supersedes its event names with the new vocabulary.) - -### Mechanism - -A trace is one causal tree of work (one run). A span is one timed operation in it, represented by ids: - -```ts -interface PiObservabilityEvent { - type: "start" | "end" | "error" | "event"; - name: string; // e.g. "pi.harness.generation" - traceId: string; - spanId?: string; - parentSpanId?: string; - timestamp: number; - durationMs?: number; - context?: Record; // user context, see below - payload?: Record; // safe attributes, see redaction - error?: { name: string; message: string }; -} - -// Runtime-agnostic core; adapters supply context propagation. -interface PiObservability { - getContext(): PiObservabilityContext | undefined; - runWithContext(context: PiObservabilityContext, fn: () => T): T; - emit(event: PiObservabilityEvent): void; - hasSubscribers(): boolean; // skip payload assembly when nobody listens -} - -function configurePiObservability(o: PiObservability): void; -function subscribePiObservability(listener: (e: PiObservabilityEvent) => void): () => void; -function runWithPiContext(userContext: Record, fn: () => T): T; -function traceOperation(name: string, payload: Record, fn: () => T): T; -``` - -`traceOperation()` reads the current context, mints `traceId` (if absent) and a fresh `spanId`, parents to the current span, emits `start`, runs the callback under the child context, then emits `end` or `error` (rethrowing). Promise-aware: `end` fires after settlement. - -Context propagation is a runtime adapter, not a core dependency: Node uses `AsyncLocalStorage` (plus optional `diagnostics_channel` publishing); browser/workers fall back to a local subscriber set with manual propagation. Concurrent runs therefore keep distinct contexts: - -```ts -await Promise.all([ - runWithPiContext({ userId: "alice" }, () => harnessA.prompt("A")), - runWithPiContext({ userId: "bob" }, () => harnessB.prompt("B")), -]); -``` - -Every span emitted inside a chain carries that chain's `context` — an OTel adapter maps it to span attributes, a log adapter prints JSON. - -### Span tree - -Aligned to the execution model; each span emits `start` + `end`/`error`: - -```text -pi.harness.run runId, sessionId, recovery -├─ pi.harness.step stepId -│ ├─ pi.harness.generation purpose, attempt, provider, model -│ │ └─ pi.ai.provider.request physical request — emitted by packages/ai -│ │ (several per generation for split-turn compaction) -│ └─ pi.harness.tool toolName, toolCallId, replay -├─ pi.harness.checkpoint deferred writes / queue consumption / compaction decision -└─ pi.harness.hook hook type — the awaited control points - -pi.harness.compaction manual operation (auto-compaction nests under its run) -pi.harness.navigation -pi.harness.resume wraps recovery work; child spans as above -pi.session.append entry type, seq — storage-level timing -``` - -Instrumentation points: the operation methods (`prompt`/`skill`/`promptFromTemplate`/`compact`/`navigateTree`/`resume`), the driver loop's step/generation/tool boundaries, hook dispatch, `Session` appends, and `streamSimple()`/`completeSimple()` in `packages/ai`. End payloads for provider requests carry safe metadata: stop reason, status code, retry count, token counts, cost, aborted/timeout flags. - -Correlation attributes are the same ids the public events carry (`ref`, `runId`, `stepId`, `toolCallId`), so a trace, the event stream, and the log line up without translation. Concurrent refs produce concurrent `pi.harness.run` traces, distinguished by `ref`. `handler_error` and `fault` are mirrored here, as specced in sections 9/10. - -### Safety and redaction - -Default payloads must be safe: - -| safe by default | unsafe — never emitted by default | -|---|---| -| provider, model, API id | prompts, completions | -| session id, entry type, tool name | tool args, tool results | -| status code, stop reason | shell output, file contents | -| token counts, costs, durations | provider request payloads, response bodies | -| retry counts, aborted/timeout flags | API keys, headers | - -Content capture (the "down to provider internals" of section 1) is opt-in via explicit redaction hooks at subscriber configuration — never ambient. - -### Subscriber contract - -- Passive, always: subscriber errors are swallowed/isolated and can never affect execution — unlike hooks, which are control-plane by design. -- Exporting, sampling, and scrubbing are the subscriber's job. Pi emits facts; it does not talk to APM vendors. -- Package layout: a minimal runtime-agnostic `packages/observability` (context + traceOperation + subscribe); `packages/ai` and `packages/agent` emit; optional adapters (`observability-node` with ALS/diagnostics_channel, an OTel bridge) live outside core. - -### Integration examples - -Harness — spans wrap the section 12 procedures; ALS context propagation makes the nesting automatic (no ids passed by hand): - -```ts -// prompt() and resume() both land here -async function executeRun(op: RunOperation): Promise { - return traceOperation("pi.harness.run", - { runId: op.id, sessionId, recovery: op.persisted }, - () => runProcedure()); -} - -// driverLoop(): one span per step, per generation, per tool -await traceOperation("pi.harness.step", { runId: op.id, stepId }, async () => { - const assistant = await traceOperation("pi.harness.generation", - { purpose: "step", attempt, provider: model.provider, model: model.id }, - () => requestAssistant()); // pi.ai.provider.request nests underneath - if (hasToolCalls(assistant)) await executeToolBatch(assistant); -}); - -// executeToolBatch(), per call -await traceOperation("pi.harness.tool", - { runId: op.id, toolName: call.name, toolCallId: call.id, replay }, - () => executeTool(call)); - -// hook dispatch and Session appends: same one-line wrapping -``` - -pi-ai — `streamSimple()` returns its stream synchronously, so the span cannot wrap the return value; it ends when the stream settles. That is the correct span boundary for a streaming API, not a workaround — the caller gets the identical stream, unchanged: - -```ts -// packages/ai/src/models.ts -streamSimple(model, context, options): AssistantMessageEventStream { - const stream = this.doStreamSimple(model, context, options); // existing body - if (!hasSubscribers()) return stream; // zero cost when idle - - // stream.result() is the existing final-message promise; the span becomes - // one more awaiter. Provider errors are in-band in pi-ai (stopReason - // "error"/"aborted" messages, not rejections), so span status derives from - // the final message; nothing propagates to the caller. - void traceOperation("pi.ai.provider.request", { - api: model.api, provider: model.provider, model: model.id, - sessionId: options?.sessionId, reasoning: options?.reasoning, - }, async () => { - const message = await stream.result(); - if (message.stopReason === "error" || message.stopReason === "aborted") { - throw new ProviderSpanError(message); // → "error" span event - } - return { stopReason: message.stopReason, ...safeUsage(message.usage) }; - }).catch(() => {}); // span error recorded; never rethrown - - return stream; -} -``` - -Application — per-request context and subscribers: - -```ts -// every span in this run carries the user context -await runWithPiContext({ userId, orgId, sessionId }, () => harness.prompt(text)); - -// console JSON sink -subscribePiObservability((e) => log.write(JSON.stringify(e))); - -// OTel bridge, in its entirety -subscribePiObservability((e) => { - if (e.type === "start") { - spans.set(e.spanId!, tracer.startSpan(e.name, { attributes: flatten(e.payload) })); - } else if (e.type === "end" || e.type === "error") { - const span = spans.get(e.spanId!); - if (!span) return; - if (e.type === "error") span.setStatus({ code: SpanStatusCode.ERROR, message: e.error?.message }); - span.end(); - spans.delete(e.spanId!); - } -}); -``` - -## 16. API examples - -```ts -// Interactive pi: single ref, nothing changes. AgentHarness implements -// AgentRef for main; suspended has 0 or 1 entries, always "main". -const { harness, suspended } = await AgentHarness.create({ session, models, model }); -for (const s of suspended) await harness.ref(s.ref)!.resume(); // or offer resume/abort in UI -await harness.prompt("fix the bug"); -await harness.steer("focus on the tests"); -harness.setModel(opus); // main's branch-anchored config - -// Slack bot: channel = session + main, each thread = a ref keyed by thread_ts. -const key = `slack:${threadTs}`; -const t = harness.ref(key) ?? (await harness.createRef(key, pingedEntryId)).ref; -await t.prompt("summarize this thread"); // parallel with main and other threads -await t.steer("shorter"); -t.setModel(haiku); // this thread only -await t.navigateTree(earlierId); // moves this thread's leaf only -await t.session.appendMessage(msg); // appends to this thread's branch -await t.nextRun("also check the links"); // consumed by this thread's next run - -// Thread renderer: scoped snapshot + only this thread's events. -const { snapshot, start, unsubscribe } = await t.watch(); -render(snapshot.transcript); -start((event) => update(event)); - -// Dashboard / server: inventory + firehose, no transcripts. -const sess = await harness.watchSession(); -for (const r of sess.snapshot.refs) { - if (r.run?.status === "suspended") await harness.ref(r.name)!.resume(); -} -``` - -## 17. Open questions - -For review (Armin): - -1. **Hook/event scoping.** Registration is harness-global; every payload carries `ref`, so handlers can scope themselves. Is that enough for API users, or do we want per-ref registration (`ref.hooks.on(...)`, `ref.events.on(...)`) with scoped delivery — e.g. a `before_tool` policy that applies to one Slack thread only? Global-with-ref is strictly more general but pushes filtering boilerplate onto every scoped consumer. -2. **Refs and replication.** Refs are stored as flat per-ref sequences without parenting, because single-writer serialization makes log order causal order within a ref (section 6). Replication/split-brain reconciliation would need explicit causality — parent pointers or equivalent — to merge two divergent copies of the same ref. Is that the substance of the harness-entries-as-trees proposal, and do we accept designing it out for now? Everything else in that proposal (partition-safe reduction, one-table storage) is covered by the `ref` field. - -## 18. Testing strategy - -TODO — after the document has been reviewed end to end. - -## 19. Implementation sequence - -TODO — after the document has been reviewed end to end. - -## 20. Required reading - -For a fresh implementation session. Read in full, in this order. This document is the authoritative design; where older docs conflict, this one wins. - -Design and contracts: - -1. `packages/agent/docs/harness.md` — this document. -2. `packages/agent/docs/agent-harness.md` — current harness contract and implementation status. -3. `packages/agent/docs/hooks.md` — prior hook/event reduction design. -4. `packages/agent/docs/observability.md` — passive tracing requirements. - -Current implementation (what is being replaced or wrapped): - -5. `packages/agent/src/agent-loop.ts` — monolithic loop to split into step primitives. -6. `packages/agent/src/agent.ts` — stateful wrapper: queues, continuation, abort, settlement. -7. `packages/agent/src/harness/agent-harness.ts` — the harness this design replaces. -8. `packages/agent/src/harness/types.ts` — entry union, storage contract, event/hook types. -9. `packages/agent/src/harness/session/session.ts` — Session, context build, entry creation. -10. `packages/agent/src/harness/session/jsonl-storage.ts` — JSONL v3 format and reload. -11. `packages/agent/src/harness/session/memory-storage.ts` — in-memory parity. -12. `packages/agent/src/harness/messages.ts` — defaultConvertToLlm and message helpers. -12a. `packages/ai/src/utils/transform-messages.ts` — orphaned-tool-call healing; the adjacency backstop referenced in sections 5 and 13. -13. `packages/agent/src/harness/compaction/compaction.ts` — preparation, split-turn generation, retry. -14. `packages/coding-agent/src/core/agent-session.ts` — old behavior to preserve in spirit: queues, bash, extensions, retry, compaction flows. -15. `packages/coding-agent/src/core/extensions/runner.ts` — old extension semantics (error isolation, before_agent_start reduction). - -SQLite backend: - -16. `packages/storage/sqlite-node/src/sqlite/storage/index.ts` — transactions, sequences, leaf state, branch materialization. -17. `packages/storage/sqlite-node/src/sqlite/storage/session-entries.ts` — encoding and validation. -18. `packages/storage/sqlite-node/src/sqlite/storage/branch-entries.ts` — active-branch materialization. -19. `packages/storage/sqlite-node/src/sqlite/storage/session-materialized.ts` — stats/labels/config projections. -20. `packages/storage/sqlite-node/src/sqlite/migrations/001_initial.sql` and `migrations.ts` — schema and migration mechanism. -21. `packages/storage/sqlite-node/src/sqlite/repo.ts` — create/open/fork. - -Behavioral tests (compatibility requirements): - -22. `packages/agent/test/agent-loop.test.ts` -23. `packages/agent/test/agent.test.ts` -24. `packages/agent/test/harness/agent-harness.test.ts` -25. `packages/agent/test/harness/session.test.ts` -26. `packages/agent/test/harness/storage.test.ts` -27. `packages/agent/test/harness/sqlite-migrations.test.ts` diff --git a/packages/agent/docs/hooks.md b/packages/agent/docs/hooks.md deleted file mode 100644 index de7230e3d88..00000000000 --- a/packages/agent/docs/hooks.md +++ /dev/null @@ -1,445 +0,0 @@ -# AgentHarness hooks design - - - -Final design. - -## Core model - -Events carry their result type as a type-only phantom: - -```ts -declare const HookResult: unique symbol; - -interface HookEvent { - type: TType; - readonly [HookResult]?: TResult; -} - -type ResultOf = E extends { readonly [HookResult]?: infer R } ? R : void; - -type HookHandler = ( - event: E, - ctx: Ctx, - signal?: AbortSignal, -) => ResultOf | void | Promise | void>; - -type HookObserver = ( - event: E, - ctx: Ctx, - signal?: AbortSignal, -) => void | Promise; -``` - -Example: - -```ts -interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> { - type: "context"; - messages: AgentMessage[]; -} - -interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> { - type: "tool_call"; - toolName: string; - input: Record; -} - -interface MessageEndEvent extends HookEvent<"message_end"> { - type: "message_end"; - message: AgentMessage; -} -``` - -No result map. No spec table. The event type defines its own result. - -## Hooks interface - -```ts -interface AgentHarnessHooks, Ctx> { - context: Ctx; - - setContext(ctx: Ctx): void; - - observe(handler: HookObserver): () => void; - - on( - type: TType, - handler: HookHandler, Ctx>, - ): () => void; - - emit( - event: TEvent, - signal?: AbortSignal, - ): Promise | undefined>; - - addCleanup(cleanup: () => void | Promise): () => void; - - clear(): Promise; - dispose(): Promise; -} -``` - -Important split: - -- `observe()` sees all events, read-only, return ignored. -- `on(type, handler)` participates in that event’s semantics. -- `emit(event)` is the only thing `AgentHarness` calls. -- `clear()` removes observers/handlers and runs cleanups. - -## Default implementation internals - -```ts -class DefaultAgentHarnessHooks, Ctx> - implements AgentHarnessHooks { - context: Ctx; - - private observers = new Set>(); - private handlers = new Map>>(); - private cleanups = new Set<() => void | Promise>(); - - constructor(ctx: Ctx) { - this.context = ctx; - } - - setContext(ctx: Ctx): void { - this.context = ctx; - } - - observe(handler: HookObserver): () => void { - this.observers.add(handler); - return () => this.observers.delete(handler); - } - - on(type, handler): () => void { - let handlers = this.handlers.get(type); - if (!handlers) { - handlers = new Set(); - this.handlers.set(type, handlers); - } - handlers.add(handler); - return () => handlers.delete(handler); - } - - async emit(event, signal?) { - for (const observer of this.observers) { - await observer(event, this.context, signal); - } - - switch (event.type) { - case "context": - return this.emitContext(event, signal); - case "before_provider_request": - return this.emitBeforeProviderRequest(event, signal); - case "before_provider_payload": - return this.emitBeforeProviderPayload(event, signal); - case "before_agent_start": - return this.emitBeforeAgentStart(event, signal); - case "tool_call": - return this.emitToolCall(event, signal); - case "tool_result": - return this.emitToolResult(event, signal); - case "session_before_compact": - case "session_before_tree": - return this.emitFirstCancelOrLast(event, signal); - default: - await this.emitObservationHandlers(event, signal); - return undefined; - } - } -} -``` - -Internal casts are acceptable inside the implementation because `Map` loses specificity. Public API remains typed. - -## Mutation semantics - -### Observation - -```ts -await hooks.emit({ type: "message_end", message }, signal); -``` - -Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type. - -### Context transform - -Handlers run in order. Each sees current messages. - -```ts -let current = event; - -for (const handler of handlers("context")) { - const result = await handler(current, ctx, signal); - if (result?.messages) { - current = { ...current, messages: result.messages }; - } -} - -return current.messages === event.messages ? undefined : { messages: current.messages }; -``` - -### Provider request / payload - -Sequential transform. Each handler sees previous output. - -```ts -let current = event; - -for (const handler of handlers("before_provider_payload")) { - const result = await handler(current, ctx, signal); - if (result !== undefined) { - current = { ...current, payload: result.payload }; - } -} - -return changed ? { payload: current.payload } : undefined; -``` - -### Before agent start - -Collect injected messages, chain system prompt. - -```ts -let systemPrompt = event.systemPrompt; -const messages = []; - -for (const handler of handlers("before_agent_start")) { - const result = await handler({ ...event, systemPrompt }, ctx, signal); - if (result?.messages) messages.push(...result.messages); - if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt; -} - -return messages.length || systemPrompt !== event.systemPrompt - ? { messages, systemPrompt } - : undefined; -``` - -### Tool call - -Sequential, early exit on block. - -```ts -for (const handler of handlers("tool_call")) { - const result = await handler(event, ctx, signal); - if (result?.block) return result; -} -``` - -### Tool result - -Sequential patch accumulation. Each handler sees current patched result. - -```ts -let current = event; -let modified = false; - -for (const handler of handlers("tool_result")) { - const result = await handler(current, ctx, signal); - if (!result) continue; - - current = { - ...current, - content: result.content ?? current.content, - details: result.details ?? current.details, - isError: result.isError ?? current.isError, - }; - - modified = true; -} - -return modified - ? { content: current.content, details: current.details, isError: current.isError } - : undefined; -``` - -### Session-before events - -Sequential, early exit on cancel. - -```ts -let last; - -for (const handler of handlers(event.type)) { - const result = await handler(event, ctx, signal); - if (!result) continue; - last = result; - if (result.cancel) return result; -} - -return last; -``` - -## Harness usage - -Harness only does this: - -```ts -await this.hooks.emit(event, signal); -``` - -or: - -```ts -const result = await this.hooks.emit({ type: "context", messages }, signal); -return result?.messages ?? messages; -``` - -Harness does not store handlers, chain listeners, or know extension policy. - -## Context - -Context is a normal object, not rebuilt per emit. - -```ts -const hooks = new CodingAgentHooks({ - harness: harnessFacade, - session: sessionFacade, - ui: noUiFacade, -}); -``` - -Later: - -```ts -hooks.setContext({ - ...hooks.context, - ui: tuiFacade, -}); -``` - -For dynamic state, prefer stable facades/methods over getter maze: - -```ts -interface CodingAgentHookContext { - harness: HarnessFacade; - session: SessionFacade; - ui: UiFacade; - models: ModelFacade; -} -``` - -Per-run `signal` is passed as the third handler arg. - -## Extension loading later - -Extension loading can live next to harness and construct hooks: - -```ts -const hooks = await loadExtensions({ - paths, - context, - hooks: new CodingAgentHooks(context), -}); -const harness = new AgentHarness({ ..., hooks }); -``` - -The loader registers into hooks: - -```ts -hooks.on("context", handler); -hooks.on("tool_call", handler); -hooks.addCleanup(cleanup); -``` - -For reload: - -```ts -await hooks.clear(); -const nextHooks = await loadExtensions(...); -harness.setHooks(nextHooks); // idle-only if supported -``` - -## Poking holes - -### 1. Error policy must be explicit - -Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely: - -```ts -errorMode: "continue" | "throw" -onError(error) -``` - -For coding-agent, default should be `"continue"`. - -### 2. Source metadata matters - -Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes. - -Probably needed: - -```ts -const scope = hooks.createScope({ sourceInfo }); -scope.on("context", handler); -scope.addCleanup(...); -``` - -Or `on(type, handler, { sourceInfo })`. - -### 3. Some extension capabilities are registries, not hooks - -These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host: - -- tools -- commands -- shortcuts -- flags -- message renderers -- provider registrations -- OAuth providers -- custom model providers - -That is fine. They do not belong in `AgentHarness`. - -### 4. Existing coding-agent events can be represented - -No blocker for: - -- `context` -- `before_provider_request` -- `after_provider_response` -- `before_agent_start` -- `message_end` -- `tool_call` -- `tool_result` -- `input` -- `user_bash` -- `resources_discover` -- `session_before_*` -- `session_*` -- model/thinking selection events -- agent/turn/message/tool lifecycle events - -They become additional event types handled by `CodingAgentHooks`. - -### 5. Need to preserve exact old semantics - -When porting coding-agent, special cases must be copied: - -- `input`: transform chain, `handled` short-circuits. -- `user_bash`: first meaningful result wins. -- `message_end`: replacement must keep same role. -- `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt. -- `resources_discover`: aggregate paths and keep extension source. -- `tool_call`: argument mutation remains visible to later handlers. -- `tool_result`: later handlers see prior patches. - -The design allows all of that, but the default/coding hooks implementation must encode it. - -### 6. `emit()` switch can miss custom mutation events - -If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially. - -### 7. Observer semantics are intentionally limited - -Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler. - -## Verdict - -This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics. - ---- Comments --- - -Thread hn2xk0tzhj on "addCleanup(cleanup" - [tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe diff --git a/packages/agent/docs/models.md b/packages/agent/docs/models.md deleted file mode 100644 index 19b72e9d18d..00000000000 --- a/packages/agent/docs/models.md +++ /dev/null @@ -1,964 +0,0 @@ -# Models architecture - -This document describes the target design for the next `pi-ai` model/provider refactor. It describes the desired shape, not the current implementation. It is intended to be complete enough to start implementing from a fresh session. - -Goals: - -- `Models` is a dumb runtime collection of providers. -- Concrete providers own metadata, auth, model listing, and stream behavior. -- API implementations live under `src/api/` and are reusable/lazy. -- Concrete provider factories live under `src/providers/`. -- Users can import only the providers they need. -- Importing a provider must not eagerly import heavy SDKs. -- Dynamic model lists are first-class: reads are sync (last-known list), fetching happens in an explicit async `refresh`. -- `models.json` and extensions layer by wrapping providers, not by mutating provider internals ad hoc. -- Old global APIs survive only in an explicit, temporary `/compat` entrypoint. - -Non-goals for the immediate `pi-ai` pass: - -- Do not migrate coding-agent `ModelRegistry` yet. -- Do not keep the stream/API registry inside `Models`. -- Do not implement web OAuth flows yet. -- Image generation mirrors the chat-side design (`ImagesModels`/`ImagesProvider` in `images-models.ts`); the old global image API (`images.ts`, `images-api-registry.ts`) lives on compat. - -## Package layout - -Target source layout: - -```txt -packages/ai/src/ - index.ts # core exports only; no built-in provider imports - models.ts # Models runtime, Provider - images-models.ts # ImagesModels runtime, ImagesProvider (mirrors models.ts) - compat.ts # temporary old-API compatibility entrypoint - auth/ # auth method types, helpers, shared resolveProviderAuth(), login callbacks - api/ # API implementations and lazy wrappers - openai-completions.ts # real implementation, imports SDKs, exports stream/streamSimple - openai-completions.lazy.ts - openai-responses.ts - openai-responses.lazy.ts - openai-codex-responses.ts - openai-codex-responses.lazy.ts - azure-openai-responses.ts - azure-openai-responses.lazy.ts - anthropic-messages.ts - anthropic-messages.lazy.ts - google-generative-ai.ts - google-generative-ai.lazy.ts - google-vertex.ts - google-vertex.lazy.ts - mistral-conversations.ts - mistral-conversations.lazy.ts - bedrock-converse-stream.ts - bedrock-converse-stream.lazy.ts - openrouter-images.ts # image-generation API implementation - openrouter-images.lazy.ts - lazy.ts # lazyStream()/lazyApi() helpers - (shared helpers: openai-responses-shared, google-shared, transform-messages, ...) - providers/ # concrete provider factories and per-provider catalogs - openai.ts - openai.models.ts # generated OpenAI catalog - openai-codex.ts - openai-codex.models.ts - anthropic.ts - anthropic.models.ts - google.ts - google.models.ts - ...one pair per built-in provider... - openrouter-images.ts # image-generation provider factory - faux.ts # test provider factory - all.ts # explicit aggregate: builtinModels(), builtinImagesModels(), getBuiltin*() - auth/oauth/ # Canonical OAuth implementations (node), lazy-loaded -``` - -`src/index.ts` must stay core-only. It must not import: - -- generated model catalogs -- built-in provider factories -- provider SDK implementations -- Node-only OAuth modules -- `providers/all` -- `compat` - -Provider, API, and compat entrypoints are explicit subpath exports. - -## Public usage - -Minimal provider usage: - -```ts -import { createModels } from "@cheetahbyte/pi-ai"; -import { openaiProvider } from "@cheetahbyte/pi-ai/providers/openai"; - -const models = createModels(); -models.setProvider(openaiProvider()); - -const model = models.getModel("openai", "gpt-4o-mini"); -if (!model) throw new Error("model not found"); - -const response = await models.complete(model, context); -``` - -Multiple providers: - -```ts -const models = createModels(); -models.setProvider(openaiProvider()); -models.setProvider(openrouterProvider()); -``` - -All built-ins, explicitly heavy metadata entrypoint: - -```ts -import { builtinModels } from "@cheetahbyte/pi-ai/providers/all"; - -const models = builtinModels(); -``` - -`providers/all` may import all provider metadata/catalogs. It still must not eagerly import SDK implementations; provider streams use lazy wrappers. - -## Core runtime: Models - -`Models` is a provider collection plus auth application and stream convenience. No stream registry, no auth resolver strategy object. - -```ts -export function createModels(options?: { - /** App-owned credential storage. Default: in-memory store. */ - credentials?: CredentialStore; - /** Environment access for auth resolution (env vars, file existence). Default: process.env/node:fs backed; injectable for tests and non-Node hosts. */ - authContext?: AuthContext; -}): MutableModels; - -export interface Models { - getProviders(): readonly Provider[]; - getProvider(id: string): Provider | undefined; - - /** Sync read of last-known models. Best-effort: a provider whose getModels() throws yields no models. */ - getModels(provider?: string): readonly Model[]; - /** Dynamic lists are honestly Model; narrow with the hasApi() guard. */ - getModel(provider: string, id: string): Model | undefined; - - /** - * Ask dynamic providers to re-fetch their model lists. With a provider id, - * rejects on that provider's failure; without, refreshes all concurrently - * best-effort. Static providers are no-ops. - */ - refresh(provider?: string): Promise; - - /** - * Resolve request auth for a model. Includes source label for status UI. - * Resolves undefined when the provider is unknown or unconfigured. Rejects - * with ModelsError ("oauth" on refresh failure, "auth" on api-key/store - * failure); status/availability UIs catch rejections and render - * "needs re-login" instead of treating them as unconfigured. - */ - getAuth(model: Model): Promise; - - stream( - model: Model, - context: Context, - options?: ApiStreamOptions, - ): AssistantMessageEventStream; - - complete( - model: Model, - context: Context, - options?: ApiStreamOptions, - ): Promise; - - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; - completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise; -} - -export interface MutableModels extends Models { - /** Upsert/replace by provider.id. Provider ids are unique. */ - setProvider(provider: Provider): void; - deleteProvider(id: string): void; - clearProviders(): void; -} -``` - -Removed concepts: - -```txt -no Models.setStreamFunctions() / getStreamFunctions() -no api-registry as a real dispatch mechanism -no Models.provider(id) builder, no setModel/upsertModel/patchModel lifecycle -no ModelAuthResolver / setAuthResolver — resolution policy is fixed, store is injected -``` - -If an app needs different auth policy, it wraps providers (wrap auth methods or `getModels`) or passes explicit request auth in stream options. - -## Provider - -A provider is the concrete runtime unit. It owns id/name/base metadata, auth methods, model listing, and stream behavior. - -`Provider` is generic over the APIs its models use. Concrete factories declare what they emit (`openaiProvider(): Provider<"openai-responses" | "openai-completions">`), giving typed model lists to direct factory users. A `Models` collection holds providers as `Provider`. - -```ts -export interface Provider { - readonly id: string; - readonly name: string; - - readonly baseUrl?: string; - readonly headers?: Record; - - /** - * Required: at least one of apiKey/oauth. Even ambient-credential providers - * (env vars, AWS profiles, ADC) and keyless local servers provide apiKey - * auth whose resolve() reports whether the provider is configured. - * getAuth() returning undefined = not configured. - */ - readonly auth: ProviderAuth; - - /** Current known models, sync. Static providers: the catalog. Dynamic providers: as of the last refresh (empty before the first). */ - getModels(): readonly Model[]; - - /** Dynamic providers only: fetch and update the model list. Concurrent calls share one in-flight fetch. */ - refreshModels?(): Promise; - - stream(model: Model, context: Context, options?: ApiStreamOptions): AssistantMessageEventStream; - - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; -} -``` - -There is no `Provider.api` field. `model.api` carries API identity; the provider dispatches internally (see `createProvider()`). - -`Model.api` remains: existing metadata and tests use it, it is useful for diagnostics, and provider construction uses it for API implementation selection. But `Models` never dispatches on it; the provider does. - -### Typed stream options - -Full stream options are API-specific. `Model` pays off by deriving the option type from the API: - -```ts -// types.ts — type-only imports from API impl modules are erased, so this is tree-shake safe -export interface ApiOptionsMap { - "anthropic-messages": AnthropicOptions; - "openai-completions": OpenAICompletionsOptions; - "openai-responses": OpenAIResponsesOptions; - "openai-codex-responses": OpenAICodexResponsesOptions; - "azure-openai-responses": AzureOpenAIResponsesOptions; - "google-generative-ai": GoogleOptions; - "google-vertex": GoogleVertexOptions; - "mistral-conversations": MistralOptions; - "bedrock-converse-stream": BedrockOptions; -} - -export type ApiStreamOptions = TApi extends keyof ApiOptionsMap - ? ApiOptionsMap[TApi] - : StreamOptions & Record; -``` - -Custom api strings fall back to the generic shape. - -### Typed model narrowing - -Runtime model lists are dynamic, so `models.getModel()`/`getModels()` honestly return `Model`. Typing improves at three points: - -1. **`hasApi()` type guard** — runtime-checked narrowing for dynamic lookups (no blind casts): - - ```ts - export function hasApi(model: Model, api: TApi): model is Model; - - const model = models.getModel("anthropic", "claude-opus-4-7"); - if (model && hasApi(model, "anthropic-messages")) { - // model: Model<"anthropic-messages">, stream options fully typed - } - ``` - -2. **`getBuiltinModel()`** — sync, generated-catalog lookup with typed overloads: `(provider, id) -> Model`. The path for hardcoded known models. - -3. **`Provider` factories** — typed model lists when using a provider directly, without a `Models` collection. - -Deliberately not done: tying `models.getModel(provider, ...)` to typed provider/model ids would require statically knowing which providers are installed in a mutable runtime collection. The harness path (`streamSimple` + `SimpleStreamOptions`) is API-agnostic and unaffected. - -For comparison: Vercel AI SDK attaches the implementation to the model object, which dissolves dispatch typing but makes models non-serializable (no sessions/RPC/catalogs as plain data), and its `providerOptions` bag is `Record` checked only by `satisfies` convention. Plain-data models + provider-owned behavior keeps stronger typing where it matters. - -### Name collision - -`types.ts` currently exports `type Provider = KnownProvider | string` (a provider id). Rename that alias to `ProviderId` and fix call sites. The `Provider` interface above takes the name. - -## Provider model listing - -Reads are sync; fetching is an explicit async verb. `Provider.getModels()` returns the current known list — the full catalog for static providers, the last-refreshed list for dynamic ones (llama.cpp, OpenRouter live listing). `refreshModels()` is where dynamic providers fetch. - -This split exists because a sync-or-async union (`Promise | T`) invites latent sync assumptions that detonate on the first async provider, while async-only reads force every consumer (UI lists, extension `find`/`getAll` surfaces) through Promises for data that is almost always static. Sync reads + explicit refresh keeps the staleness visible and the contract single: `getModels()` = last known, `refresh()` = make it current. A fetched list is stale the moment it returns anyway; naming the refresh point is honest about it. - -Apps own the refresh lifecycle: startup, registry reload, opening a model selector. Freshness-critical lookups are two-step: `await models.refresh("llamacpp"); models.getModel("llamacpp", id)`. - -Dynamic refresh must be side-effect-free discovery: - -```txt -OK: fetch /v1/models, enumerate local catalog, refresh cached remote model list -Not OK: load model, download model, mutate server state, run request probe -``` - -Provider-specific model lifecycle (load/unload) belongs in app/provider-management commands, not in `refreshModels()`. - -## Streaming path - -`Models.stream()` finds the provider by `model.provider`, resolves auth, merges it into request options, and delegates: - -```ts -function stream(model, context, options) { - const provider = this.getProvider(model.provider); - if (!provider) { - // produce an error stream, not a throw — see Error behavior - } - - // async setup happens inside the returned stream (lazyStream pattern) - const resolution = await this.getAuth(model); - const requestModel = resolution?.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model; - const requestOptions = mergeAuth(options, resolution?.auth); // explicit options win per-field - - return provider.stream(requestModel, context, requestOptions); -} -``` - -`stream()` returns `AssistantMessageEventStream` synchronously; async setup (auth resolution, lazy module load) happens inside the returned stream. The forwarding pattern already exists in today's `register-builtins.ts` (`createLazyStream`); extract it as `lazyStream()` in `src/api/lazy.ts`. - -No request hot-path model canonicalization: `stream()` uses the supplied model object as-is. If an app wants fresh model metadata, it refreshes the provider and re-reads (`await models.refresh(p); models.getModel(p, id)`) before starting the turn. - -## API implementations under `src/api` - -An API implementation is reusable stream behavior. It is not a provider. - -Uniform export contract — every real implementation module exports exactly: - -```ts -// src/api/anthropic-messages.ts — imports SDKs -export function stream(model, context, options) { ... } -export function streamSimple(model, context, options) { ... } -``` - -This makes the module itself satisfy `ProviderStreams`, so the lazy wrapper is one generic helper instead of bespoke per-API plumbing. `ProviderStreams` is the untyped dispatch shape (implementation modules export concretely typed functions, which would not be assignable to a generic method); per-API option typing lives on the modules themselves and on `Provider.stream()` via `ApiStreamOptions`: - -```ts -export interface ProviderStreams { - stream(model: Model, context: Context, options?: StreamOptions): AssistantMessageEventStream; - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; -} - -// src/api/lazy.ts -export function lazyApi(load: () => Promise): ProviderStreams; - -// src/api/anthropic-messages.lazy.ts -export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts")); -``` - -Import chain: - -```txt -provider module -> lazy API wrapper -> dynamic import(real API impl) -> SDK deps -``` - -Notes: - -- Bedrock keeps the node-only dynamic import trick (`importNodeOnlyProvider`, `.ts`/`.js` specifier rewrite) inside its lazy wrapper. `setBedrockProviderModule()` (used by the Bun build) moves into the bedrock lazy wrapper module. -- Shared helper modules (`openai-responses-shared.ts`, `google-shared.ts`, `transform-messages.ts`, prompt-cache, copilot headers) move to `src/api/` alongside the implementations. - -## Shared API implementations across concrete providers - -Many concrete providers share an API implementation (OpenAI-completions: OpenRouter, Groq, Cerebras, xAI, ZAI, ...). They share lazy API objects by reference: - -```ts -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; - -export function openrouterProvider(): Provider { - return createProvider({ - id: "openrouter", - name: "OpenRouter", - baseUrl: "https://openrouter.ai/api/v1", - auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) }, - models: OPENROUTER_MODELS, - api: openAICompletionsApi(), - }); -} -``` - -This copies Vercel AI SDK's useful property: users import concrete providers; shared protocol implementation is internal. - -## Auth - -Request auth output stays small: - -```ts -export interface ModelAuth { - apiKey?: string; - headers?: Record; - baseUrl?: string; -} -``` - -If a value cannot be expressed as `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth (Vertex project/location, Bedrock region/profile, Azure apiVersion are provider factory options). - -### Provider auth - -`Provider.auth` has exactly two slots; real providers have at most one api-key path and at most one OAuth path, and the slot names carry the UI's oauth-vs-api-key split without a `kind` discriminant or method ids: - -```ts -export interface ProviderAuth { - apiKey?: ApiKeyAuth; // stored key/provider env + ambient env/files/ADC/IAM - oauth?: OAuthAuth; // login flow + refresh -} - -export interface ApiKeyAuth { - name: string; // "Anthropic API key" - - /** Interactive setup (prompt for key/provider env). Absent = ambient-only (env, ADC, IAM). */ - login?(interaction: AuthInteraction): Promise; - - /** - * Resolve auth from the stored credential and/or ambient sources, merging - * per field (credential.key ?? env("..."), credential.env?.NAME ?? env("...")). - * undefined = not configured. - */ - resolve(input: { - model: Model; - ctx: AuthContext; - credential?: ApiKeyCredential; - }): Promise; -} - -export interface OAuthAuth { - name: string; // "Anthropic (Claude Pro/Max)" - - login(interaction: AuthInteraction): Promise; - - /** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */ - refresh(credential: OAuthCredential): Promise; - - /** Side-effect-free derivation of request auth from a valid credential. Covers Copilot-style per-credential baseUrl. Async so lazy wrappers can load the implementation. */ - toAuth(credential: OAuthCredential): Promise; -} - -export interface AuthResult { - auth: ModelAuth; - /** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */ - source?: string; -} - -export interface AuthContext { - env(name: string): Promise; - fileExists(path: string): Promise; // supports leading ~ -} -``` - -The `refresh`/`toAuth` split lets `Models` own the locked refresh pattern without closure gymnastics: refresh produces a credential, while `toAuth` derives request auth from whatever credential ends up stored. - -OAuth implementations use the provider-neutral `AuthInteraction` protocol directly. A callback-server flow issues a `manual_code` prompt racing the server and aborts the prompt when the callback wins, so the UI needs no provider-specific callback or static callback-server flag. - -### Credentials - -One credential per provider, type-tagged — exactly the shape of today's auth.json (`type: "api_key" | "oauth"` per provider id): - -```ts -export interface ApiKeyCredential { - type: "api_key"; - key?: string; - env?: ProviderEnv; // e.g. Cloudflare account/gateway ids, Azure/Vertex/Bedrock scoped config -} - -export interface OAuthCredential extends OAuthCredentials { - type: "oauth"; // access, refresh, expires from OAuthCredentials -} - -export type Credential = ApiKeyCredential | OAuthCredential; -``` - -`ApiKeyCredential.env` stores provider-scoped environment/config values alongside or instead of a key. `ApiKeyAuth.resolve()` merges per field: `credential.key ?? env("CLOUDFLARE_API_KEY")`, `credential.env?.CLOUDFLARE_ACCOUNT_ID ?? env("CLOUDFLARE_ACCOUNT_ID")`, etc. The credential discriminator intentionally matches today's `auth.json` (`api_key`) so the file-backed store does not need lossy type translation. - -### Credential store - -The app injects storage; `pi-ai` ships an in-memory default. Keyed by provider id, one credential per provider: - -```ts -export interface CredentialStore { - /** Read the stored credential, possibly expired. Display/status use; request auth comes from Models.getAuth(). */ - read(providerId: string): Promise; - - /** - * Serialized write — the only write path. fn sees the current credential - * because correct writes (refresh, login-during-refresh) depend on it; - * return the new credential, or undefined to leave the entry unchanged. - * Mutual exclusion per provider id, cross-process too where the backing - * store supports it (file lock). Resolves with the post-write credential. - */ - modify( - providerId: string, - fn: (current: Credential | undefined) => Promise, - ): Promise; - - /** Remove (logout). Serialized against modify. */ - delete(providerId: string): Promise; -} -``` - -There is deliberately no `set`: an unserialized write path invites read-modify-write races (login-during-refresh clobbering a fresh credential, double token refresh). Call sites: - -```ts -await store.modify(pid, async () => credential); // login: store this -await store.read(pid); // status UI ("logged in via OAuth") -await store.delete(pid); // logout -// refresh RMW happens inside Models.getAuth -``` - -Error semantics: `read` resolves `undefined` for missing entries; methods reject only on storage failure, and `Models` wraps such rejections in `ModelsError` code `"auth"`. Best-effort stores that serve an in-memory view and record persistence errors internally (today's AuthStorage behavior) are valid implementations. - -### Resolution policy (fixed) - -`Models.getAuth(model)` is a decision tree, not a loop. A stored credential owns the provider — ambient/env is consulted only when nothing is stored (AuthStorage parity: no silent env fallback after a failed refresh or for an unmatched credential type): - -```ts -const stored = await store.read(provider.id); -if (stored) { - if (stored.type === "oauth" && provider.auth.oauth) { - const oauth = provider.auth.oauth; - let credential = stored; - if (Date.now() >= credential.expires) { // optimistic check, lock-free - const post = await store.modify(provider.id, async (current) => { - if (current?.type !== "oauth") return undefined; // logged out meanwhile - return Date.now() >= current.expires // authoritative check, under lock - ? oauth.refresh(current) // throws -> ModelsError("oauth") - : undefined; // another process/request refreshed - }); - if (post?.type !== "oauth") return undefined; - credential = post; - } - return { auth: await oauth.toAuth(credential), source: "OAuth" }; - } - if (stored.type === "api_key" && provider.auth.apiKey) { - return provider.auth.apiKey.resolve({ model, ctx, credential: stored }); - } - return undefined; // stored credential without matching handler blocks ambient -} -return provider.auth.apiKey?.resolve({ model, ctx, credential: undefined }); // ambient -``` - -Properties: - -- Double-checked locking, same as today's `refreshOAuthTokenWithLock`: valid tokens cost one `read` and zero locks; expired tokens lock, re-check under the lock, refresh once globally, persist before release. -- Explicit request auth (stream options `apiKey`/`headers`) is merged per-field on top in `stream()`, winning over everything. -- Refresh failure rejects with `ModelsError("oauth")`; the stored credential is untouched (preserved for retry). Request paths surface this as a stream error with the real cause ("run /login"); status/availability UIs catch the rejection and render "needs re-login" — documented contract on `getAuth`. - -### Replacing AuthStorage - -The end state for coding-agent: AuthStorage is deleted; its capabilities map onto a `CredentialStore` implementation plus composition. - -Today's `getApiKey` priority and its new home: - -| AuthStorage today | New design | -|---|---| -| runtime override (CLI `--api-key`) | `withRuntimeOverrides(store, overrides)` decorator: `read` returns the override as an `ApiKeyCredential`; never persisted | -| stored `api_key` (with `$ENV`/`!command` via `resolveConfigValue`) | stored `ApiKeyCredential`; config-value resolution happens at `read` in coding-agent's adapter/decorator (command execution stays app policy) | -| stored `oauth` + locked refresh, undefined on failure | `getAuth` decision tree above; failure rejects with cause instead of silently unconfiguring | -| env var (only when nothing stored) | ambient branch of `apiKey.resolve` | -| `fallbackResolver` (models.json custom providers) | gone — custom providers carry their own `auth.apiKey` | - -```txt -FileCredentialStore ports AuthStorage's lock backend: read = memory snapshot, - modify = withLockAsync(re-read, fn, merge-write), delete, - internal error recording (drainErrors equivalent) -└─ withConfigValues $ENV / !command at read - └─ withRuntimeOverrides --api-key - └─ createModels({ credentials: store }) - -login/logout UI provider.auth.{oauth,apiKey}.login(interaction) + store.modify/delete -status UI store.read(pid) + getAuth try/catch ("needs /login" on rejection) -getOAuthProviders presence of provider.auth.oauth across registered providers -``` - -### Login callbacks - -One interface serves api-key and OAuth login: - -```ts -export interface AuthInteraction { - /** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */ - signal?: AbortSignal; - - prompt(prompt: AuthPrompt): Promise; - notify(event: AuthEvent): void; -} - -/** `signal` lets the flow cancel a pending prompt when an out-of-band event resolves the step. */ -export type AuthPrompt = { signal?: AbortSignal } & ( - | { type: "text"; message: string; placeholder?: string } - | { type: "secret"; message: string; placeholder?: string } - | { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] } - | { type: "manual_code"; message: string; placeholder?: string } -); - -export type AuthEvent = - | { type: "auth_url"; url: string; instructions?: string } - | { type: "device_code"; userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number } - | { type: "progress"; message: string }; -``` - -`prompt()` returns the entered/selected string (`select` returns the option id). Flows race a `manual_code` prompt against a callback server by setting `AuthPrompt.signal` and aborting the prompt when the callback wins. - -### OAuth attachment - -Providers that support OAuth always attach it. There is no factory toggle: the flow is lazy-loaded, so advertising OAuth costs nothing until `login()`/`refresh()` actually runs, and a host that never logs in never loads it. - -```ts -export function anthropicProvider(): Provider { - return createProvider({ - id: "anthropic", - name: "Anthropic", - baseUrl: "https://api.anthropic.com/v1", - auth: { - apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]), - oauth: lazyOAuth({ - name: "Anthropic (Claude Pro/Max)", - load: () => import("../auth/oauth/anthropic.ts").then((m) => m.anthropicOAuth), - }), - }, - models: ANTHROPIC_MODELS, - api: anthropicMessagesApi(), - }); -} -``` - -`lazyOAuth()` wraps a dynamically imported `OAuthAuth` so provider definitions can advertise OAuth without importing the implementation (`toAuth` is async for exactly this reason): - -```ts -export function lazyOAuth(input: { - name: string; - load: () => Promise; -}): OAuthAuth; -``` - -OAuth must not force Node-only code (`node:http`, `node:crypto`) into browser bundles: the dynamic import inside `lazyOAuth()` uses the same bundler-opaque variable-specifier trick as the bedrock lazy wrapper. Browser hosts never trigger the load (no stored node OAuth credentials, no login flow). If web OAuth lands later (sitegeist proved feasibility: Web Crypto PKCE, auth tab, fetch token exchange, device-code polling), it is just a different `OAuthAuth` implementation — no reserved option values. - -The built-in flows in `src/auth/oauth/` implement `OAuthAuth` and `AuthInteraction` directly while remaining Node-targeted and lazy-loaded. Copilot derives its credential-specific request endpoint through `toAuth().baseUrl`. - -## Provider wrappers and models.json - -`models.json` is a provider wrapper layer. It does not mutate providers in place: - -```ts -function withProviderOverrides(base: Provider, overrides: ProviderOverrides): Provider { - return { - ...base, - name: overrides.name ?? base.name, - baseUrl: overrides.baseUrl ?? base.baseUrl, - headers: mergeHeaders(base.headers, overrides.headers), - - getModels: () => applyModelOverrides(base.getModels(), overrides.models), - refreshModels: base.refreshModels?.bind(base), - - stream: base.stream, - streamSimple: base.streamSimple, - }; -} -``` - -This composes with dynamic providers because `getModels()` delegates to the base source and `refreshModels()` passes through. - -Request-auth config from models.json (`$ENV`, `!command`, inline keys) remains app-owned sidecar state, surfaced either as explicit request auth or as a custom `ApiKeyAuth` the app sets on the wrapped provider's `auth.apiKey`. - -## Custom providers: createProvider() - -One helper builds providers from parts; it handles both single-API and mixed-API providers: - -```ts -export function createProvider(input: { - id: string; - name?: string; // default: id - baseUrl?: string; - headers?: Record; - auth: ProviderAuth; // required, at least one of apiKey/oauth (no "no-auth" providers) - /** Initial model list (empty for purely dynamic providers). */ - models: readonly Model[]; - /** Dynamic providers: fetch the current list; createProvider stores it and dedupes in-flight calls. */ - refreshModels?: () => Promise[]>; - /** Single implementation, or map keyed by model.api for mixed-API providers. */ - api: ProviderStreams | Record; -}): Provider; -``` - -- Single `api`: all models stream through it. -- Map `api`: `stream()`/`streamSimple()` dispatch on `model.api`; unknown api produces a stream error. - -Mixed-API custom providers must be supported (opencode Go/Zen-style providers expose models backed by different APIs under one provider id). - -Built-in provider factories use `createProvider()` internally. models.json custom providers map onto it directly: - -```json -{ - "providers": { - "my-openai-proxy": { - "api": "openai-completions", - "baseUrl": "https://proxy.example/v1", - "models": [ ... ] - } - } -} -``` - -## Compat entrypoint - -`@cheetahbyte/pi-ai/compat` preserves the old global API surface until the coding-agent migration deletes it. New code never imports it. - -Old semantics being preserved: global `stream()` can still dispatch by `model.api` through the legacy api-registry for custom providers, mutated models, and tests/extensions that override a built-in API implementation. - -- `stream/complete/streamSimple/completeSimple(model, ctx, opts)`: real built-in provider/model/api matches route through a singleton `builtinModels()` collection, so provider auth/env/baseUrl behavior is shared with the new runtime. Unknown providers, mutated models, or overridden API registrations fall back to api-registry dispatch plus `getEnvApiKey` injection. -- The builtin api registration side effect moves from the root barrel into compat. It skips api ids that already have a registration, since compat may load after a test or extension has already registered an override. `registerApiProvider()/unregisterApiProviders()` keep feeding the compat-local registry; `resetApiProviders()` clears and re-registers builtins. -- Sync `getModel/getModels/getProviders` are deprecated aliases of `getBuiltinModel/getBuiltinModels/getBuiltinProviders` from `providers/all` (they were always pure generated-catalog reads — verified: nothing ever mutated the old `modelRegistry`). -- Re-exports the per-API lazy stream wrappers (incl. `setBedrockProviderModule`), `env-api-keys.ts`, and the image-generation registry/catalogs; none of these stay on the root barrel. -- `export * from "./index.ts"`: compat is a strict superset of the core entrypoint, so consumers switch a file's import path wholesale without symbol surgery. - -coding-agent (and the interim agent package) switch imports of these symbols from `@cheetahbyte/pi-ai` to `@cheetahbyte/pi-ai/compat` (import-path-only change) and are otherwise untouched until the ModelManager migration. - -Extension grace period: the coding-agent extension loader (jiti aliases + Bun `virtualModules`) resolves the `@cheetahbyte/pi-ai` ROOT specifier to the compat entrypoint. Existing user extensions using the old global API (`complete`, `getModel`, `registerApiProvider`, ...) keep working at runtime without changes; they break only when compat is removed at the ModelManager migration, with a migration guide in the changelog. Typechecking is the nudge: editors resolve the root to the slim core types, so extension sources that typecheck must import old globals from `/compat` — which is what the repo example extensions demonstrate. - -## Builtin static helpers - -Typed, sync, generated-catalog-only helpers live with the catalogs (exported from `providers/all`): - -```ts -getBuiltinModel(provider, id) // sync, typed overloads from generated catalog -getBuiltinModels(provider) // sync -getBuiltinProviders() // sync -``` - -Runtime lookup through a `Models` instance is sync over the last-known provider lists: `models.getModel(...)`. Freshness-critical callers run `await models.refresh(provider)` first. - -Generated catalogs are split per provider (`providers/.models.ts`) by updating `packages/ai/scripts/generate-models.ts`. If the generator change turns out too large for this pass, splitting may be deferred; `providers/all` and provider factories may temporarily import the monolithic `models.generated.ts`, relying on `sideEffects: false` for pruning. - -## Tree-shaking and lazy imports - -Rules: - -1. Main `@cheetahbyte/pi-ai` import is core-only. -2. Provider modules import their catalog, auth helpers, and lazy API wrappers only. -3. Lazy API wrappers dynamically import real API implementations. -4. Real API implementations import SDK dependencies. -5. OAuth implementations are always attached via `lazyOAuth()` and lazy-loaded behind a bundler-opaque dynamic import; provider metadata never eagerly imports Node-only OAuth code. -6. `providers/all` imports every built-in provider factory and all catalogs. It is the explicit heavy entrypoint. -7. Provider modules are side-effect-free; importing a provider does not register anything globally. -8. `package.json` lists only effectful compat/image registration files in `sideEffects`; root and provider modules stay tree-shakeable. -9. With code splitting, provider SDKs stay in lazy chunks. Without code splitting, bundlers fold statically reachable lazy API implementations into the single bundle; `providers/all` then pulls all statically visible SDKs. Bedrock is the exception because its AWS SDK implementation is behind a bundler-opaque Node-only import and needs `setBedrockProviderModule()` for standalone single-file bundles. - -Exports map sketch: - -```json -{ - "exports": { - ".": "./dist/index.js", - "./compat": "./dist/compat.js", - "./providers/all": "./dist/providers/all.js", - "./providers/openai": "./dist/providers/openai.js", - "./providers/anthropic": "./dist/providers/anthropic.js", - "./providers/*": "./dist/providers/*.js", - "./api/*": "./dist/api/*.js" - } -} -``` - -Browser smoke check (`scripts/check-browser-smoke.mjs`) must keep passing: bundling the core entrypoint (and any non-node provider entrypoint) must not pull `node:http`/`node:crypto`. - -## AgentHarness integration - -`AgentHarness` receives a `Models` instance. - -- `AgentHarnessOptions.models` is required. -- The harness does not snapshot `Models` into turn state. -- Request path calls `this.models.streamSimple(model, context, options)`; same for compaction/branch-summarization paths. -- Request path never calls async `models.getModel()` to canonicalize; if model metadata needs refresh, the app updates the selected model before starting a turn. -- Harness tests build `createModels()` and install the faux provider (`fauxProvider()` factory from `providers/faux`). - -## coding-agent next phase (not this pass) - -coding-agent builds providers in layers and binds them per session: - -```txt -built-in providers (builtinModels) --> models.json provider wrappers / custom providers (createProvider) --> extension provider wrappers/additions -``` - -```ts -sessionModels.clearProviders(); -for (const provider of layeredProviders) sessionModels.setProvider(provider); -``` - -coding-agent owns: `FileCredentialStore` + decorators replacing AuthStorage (see "Replacing AuthStorage"), models.json auth sidecar (`$ENV`, `!command`), command execution policy, provider status labels (from `AuthResult.source`), login/logout UI (driving `auth.{apiKey,oauth}.login()` with `prompt()/notify()`), extension lifecycle, provider-management slash commands. - -Current interim state: - -- `AgentHarness` already accepts a `Models` instance and uses it for turn streaming, compaction, and branch summaries. -- coding-agent does not use `AgentHarness` yet; `AgentSession` still drives the low-level `Agent` with a `streamFn`. -- coding-agent still uses legacy `AuthStorage` + `ModelRegistry` and imports old global pi-ai APIs through `@cheetahbyte/pi-ai/compat`. -- The extension loader still aliases the pi-ai root to `/compat` as the runtime grace period for old extensions. - -## Implementation TODOs - -Check items off as they land. Keep this list current; it is the working state for resumed sessions. - -### Phase 1 — core types/runtime - -- [x] Rename `types.ts` `Provider` alias to `ProviderId`; fix call sites. -- [x] Add `ApiOptionsMap` and `ApiStreamOptions` to `types.ts` (type-only imports). -- [x] New `models.ts`: `Provider` interface, `hasApi()` guard, `ModelsError` + codes. Auth types live in `src/auth/types.ts` (`ProviderAuth` = `{ apiKey?, oauth? }`, credentials, `CredentialStore` (`read`/`modify`/`delete`, one credential per provider), `AuthResult`, `AuthContext`, `ModelAuth`, login callbacks), in-memory store in `src/auth/credential-store.ts`, default context in `src/auth/context.ts` (browser-safe node:fs trick), `lazyStream()` in `src/api/lazy.ts`. -- [x] `Models`/`MutableModels`/`createModels({ credentials?, authContext? })` with provider map, sync `getModel(s)` (per-provider failure isolation), explicit async `refresh(provider?)`, `getAuth` (decision tree, double-checked locked refresh), `stream/complete/streamSimple/completeSimple` with per-field auth merge. Tests: `packages/ai/test/models-runtime.test.ts`. -- [x] Keep metadata helpers: `calculateCost`, `getSupportedThinkingLevels`, `clampThinkingLevel`, `modelsAreEqual`. - -### Phase 2 — `src/api/` - -- [x] Move stream implementations from `src/providers/` to `src/api/`, renamed by API id (`anthropic.ts` -> `api/anthropic-messages.ts`, etc.). -- [x] Normalize each implementation module to export exactly `stream` and `streamSimple`. -- [x] Move shared helpers (`openai-responses-shared`, `google-shared`, `transform-messages`, `openai-prompt-cache`, `github-copilot-headers`, `cloudflare`, `simple-options`) to `src/api/`. -- [x] Extract `lazyStream()`/`lazyApi()` into `src/api/lazy.ts`. -- [x] Add `*.lazy.ts` wrappers per API; bedrock keeps node-only import trick and `setBedrockProviderModule()`. -- [x] Delete `providers/register-builtins.ts`. Interim until Phase 5 compat: builtin api-registry registration lives in `stream.ts`; lazy API wrappers are exported from the root barrel. - -### Phase 3 — provider factories + catalogs - -- [x] Auth helpers in `src/auth/helpers.ts`: `envApiKeyAuth()` (with secret-prompt `login`), `lazyOAuth()`. OAuth flow loads go through `auth/oauth/load.ts` (bundler-opaque dynamic import); the `OAuthAuth` exports it references land in Phase 4. -- [x] `createProvider()` in `models.ts` (single + mixed `api` map, dispatch on `model.api`, unknown api -> stream error). -- [x] Per-provider factories under `src/providers/` for all built-in catalog providers; OAuth attached via `lazyOAuth()` (anthropic, openai-codex, github-copilot); ambient `ApiKeyAuth` for amazon-bedrock (AWS env/profile) and google-vertex (key or ADC+project+location). -- [x] `providers/all.ts`: `builtinProviders()`, `builtinModels()`, `getBuiltinModel/getBuiltinModels/getBuiltinProviders` re-exports. -- [x] Faux provider factory (`fauxProvider()` in `providers/faux.ts`) for tests; legacy `registerFauxProvider()` kept until compat dies. -- [x] Split generated catalogs per provider via `scripts/generate-models.ts` (`providers/.models.ts`); `models.generated.ts` becomes a generated aggregator. - -### Phase 4 — OAuth adaptation - -- [x] Built-in implementations live under `auth/oauth/` and implement `OAuthAuth` directly through `AuthInteraction.prompt()`/`notify()`. They are private provider implementations loaded lazily by provider factories. -- [x] Callback-server flows race a `manual_code` prompt, aborted through `AuthPrompt.signal` once the flow settles. The public `oauth` subpath retains only coding-agent extension compatibility types. - -### Phase 5 — packaging - -- [x] `index.ts` core-only and side-effect free (no catalogs, no provider factories, no api-registry, no env-api-keys, no images, no OAuth, no compat). Typed catalog reads (`getBuiltin*`) implemented in `providers/all.ts`; `models.ts` no longer imports `models.generated.ts`. -- [x] `compat.ts`: superset of index + old api-dispatch globals, deprecated `getModel/getModels/getProviders` aliases, lazy api wrappers + `setBedrockProviderModule`, `getEnvApiKey`, images. Registration side effect lives here (skip-if-present). -- [x] Subpath exports map (`./compat`, `./providers/*`, `./api/*`); `sideEffects` array listing the effectful modules (`compat`, images registration) instead of `false`. -- [x] Browser smoke (entry now imports old globals from `/compat`) + shrinkwrap checks green. Internal old-global imports switched to `/compat` already (42 files in agent/coding-agent/examples; vitest configs alias `/compat` to src; spawn-CLI tests resolve workspace dist, so `packages/ai` + `packages/agent` dists were rebuilt). - -### Phase 6 — AgentHarness - -- [x] `AgentHarnessOptions.models` required (`readonly models` on the harness); the harness stream path uses `models.streamSimple()`. `StreamFn` redefined structurally (no compat type dependency); `Models.streamSimple` satisfies it. -- [x] Compaction/branch-summarization take the harness `Models` instance. `getApiKeyAndHeaders` is removed entirely — `Models` is the only auth path; per-request key resolution becomes provider auth on the collection. `compact()`/`generateSummary()`/`generateBranchSummary()` lose their explicit `apiKey`/`headers` parameters. -- [x] Harness tests use `createModels()` + `fauxProvider()` with unique per-fake provider ids; no global api-registry state, no unregister bookkeeping. - -### Phase 7 — coding-agent bridge (minimal) - -- [x] Switch old-global imports to `@cheetahbyte/pi-ai/compat` (landed with Phase 5; compat is a superset so the switch was path-only). Extension loader resolves the pi-ai root to compat as the runtime grace period. -- [x] Everything else originally sketched here is gated on coding-agent actually streaming through a `Models` instance — coding-agent's `AgentSession` drives the low-level `Agent` via `streamFn`, not the harness — and moved to Phase 9. - -### Phase 8 — wrap-up - -- [x] Update/add tests; run affected suites (tests landed with each phase; `./test.sh` green throughout). -- [x] `packages/ai/CHANGELOG.md`: `### Breaking Changes` with migration guide (compat entrypoint, `Provider` -> `ProviderId`, api module moves) + `### Added` for the new Models/provider/auth API. -- [x] `packages/coding-agent/CHANGELOG.md`: `### Changed` entry for extension authors — runtime unaffected (loader resolves the pi-ai root to compat), typecheck nudges to `/compat` or the new API; removal happens later with a migration guide. -- [x] `packages/agent/CHANGELOG.md`: `### Breaking Changes` for required `AgentHarnessOptions.models`, compaction signature changes, structural `StreamFn`. -- [x] `npm run check` clean. - -### Phase 9 — coding-agent on Models + CredentialStore (in scope) - -coding-agent replaces AuthStorage and ModelRegistry's internals with `FileCredentialStore` + a `MutableModels` collection. AgentSession itself stays (AgentHarness adoption is pi 2.0); only its model/auth substrate swaps. Layering is strictly one-directional: - -```txt -FileCredentialStore (auth.json, locked, $ENV/!command resolution) + explicit --api-key overlay - ↑ -MutableModels: builtin factories (wrapped per models.json config) + custom providers (models.json ∪ extensions) - ↑ -ModelRegistry: compatibility facade — sync last-known reads delegate to the collection; registerProvider/login/logout/status for extensions + UI - ↑ -AgentSession / sdk / interactive-mode (stream via models; await only auth/refresh paths) -``` - -Decisions: - -- `AuthStorage` is deleted as a type — it would otherwise depend on provider auth while provider auth depends on its store (circular). Its surface splits: `get`/`set`/`remove` -> `CredentialStore`; `getApiKey` -> `Models.getAuth`; `login`/`logout`/`getAuthStatus` -> ModelRegistry facade methods over `provider.auth.oauth` + the store. -- `FileCredentialStore` is self-contained (path, locking, parse/write, chmod, error buffering) and owns `auth.json` semantics, including `$ENV`/`!command` resolution for stored API-key credentials. Persisted values stay raw; resolution returns copies for auth use. -- Runtime `--api-key` overrides are an explicit store overlay (an override reads as an ephemeral stored api-key credential, masking stored OAuth — matches today's priority). Every registered provider is guaranteed an `apiKey` auth slot so overrides apply to OAuth-only providers too. -- `ModelRegistry.getAll`/`find`/`getAvailable` stay sync for SDK and extension compatibility, delegating to the collection's last-known sync model lists and fast configured-looking status checks. Dynamic providers update through explicit async `refresh()`, and request auth remains async through `getApiKeyAndHeaders()`/`Models.getAuth()`. Extensions also get the collection itself as the forward API. -- models.json keeps FULL feature parity, implemented as provider decoration: builtin factories wrapped so `getModels()` applies provider `baseUrl`/`compat` overlays, `modelOverrides`, and custom-model merges (async-safe); provider `apiKey`/`headers`/`authHeader` configs become that provider's `ApiKeyAuth` (config first, factory auth fallback); parse errors keep `getError()` semantics. -- Extension `ProviderConfig` parity: provider-keyed `streamSimple`, legacy extension OAuth callbacks adapted to `OAuthAuth`, and full model replacement per provider. Legacy `registerApiProvider` writes stay compat-local for consumers that call global `complete()`; they die with compat. -- Copilot: stored-credential baseUrl applied in the wrapped `getModels()` (extension-visible models stay correct) plus per-request `toAuth().baseUrl`. -- Cloudflare: provider-auth substitution (key + `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` from credential `env` or ambient `AuthContext.env()` -> `ModelAuth.baseUrl`). Built-in compat calls route through `Models`, so they use the same provider auth path. - -Ordering for new sessions: - -1. [x] pi-ai rework first: `Provider.getModels()` sync + optional `refreshModels()`; `Models.getModels`/`getModel` sync, `Models.refresh(provider?)` async; `createProvider` takes `models` array + optional `refreshModels` fetcher (in-flight dedupe). Reverses Phase 1's async-listing decision — see "Provider model listing" for rationale (sync-or-async unions breed latent sync assumptions; async-only breaks sync consumer surfaces like extension `find`/`getAll`). -2. [x] Cloudflare provider auth in pi-ai factories: Workers AI and AI Gateway validate their required account/gateway env/config and return resolved `baseUrl`, provider-scoped env, and header suppression/override metadata from provider auth. -3. [ ] Add `FileCredentialStore` in coding-agent. - - Implement the pi-ai `CredentialStore` interface as a self-contained `auth.json` store; do not depend on the old `AuthStorageBackend` abstraction, though its lock/retry semantics may be ported. - - Preserve the existing file format. `ApiKeyCredential` uses `{ type: "api_key", key?, env? }`, matching today's `auth.json`; do not translate `env` into metadata or rewrite discriminators. - - Resolve `$ENV`/`!command` in stored API-key `key` and `env` values out of the box using an injected execution/config environment. `$ENV` lookup should come from that environment, and `!command` should run through the shared shell execution path rather than direct `execSync`. - - Persist raw config values; resolved credentials returned for auth use must be copies and must not rewrite `$ENV`/`!command` strings unless a caller explicitly stores new values. - - `read(provider)` returns the current credential snapshot and records parse/storage errors for status UI parity. - - `modify(provider, fn)` must lock, re-read, run `fn`, merge-write the provider entry, chmod `0600`, and return the post-write credential. - - `delete(provider)` must lock and remove only that provider's entry. - - Add file-backed and in-memory tests covering lock/RMW behavior, `api_key` reads with config-value resolution, OAuth reads, provider `env` preservation, delete, parse errors, and concurrent refresh-style modifications. -4. [ ] Add runtime override overlay for coding-agent policy. - - `withRuntimeOverrides(store, overrides)` implements CLI `--api-key`: read returns an ephemeral `{ type: "api_key", key }` for each overridden provider, masking stored OAuth/API credentials without persisting. - - Runtime overrides must apply even to OAuth-capable providers; every provider registered in coding-agent must retain or gain an `apiKey` auth slot so the overlay is meaningful. - - Tests cover precedence: runtime override > stored credential > models.json config auth > ambient provider env, with stored credential blocking ambient fallback. -5. [ ] Build provider decoration helpers for `models.json`. - - Start from built-in provider factories, not generated model arrays. - - Wrap provider `getModels()` so provider-level `baseUrl`/`headers`/`compat`, per-model `modelOverrides`, and custom model merges apply on every sync read. - - Preserve `refreshModels()` passthrough so dynamic providers compose with decorations. - - Convert provider `apiKey`/`headers`/`authHeader` models.json config into a wrapped `ApiKeyAuth` that resolves config values first and falls back to the base provider auth. - - Custom providers with `models` use `createProvider()` with the appropriate lazy API wrapper or extension-provided stream implementation. - - Parse errors must keep current `ModelRegistry.getError()` behavior: built-ins remain available, and the error is visible. -6. [ ] Copilot `getModels()` baseUrl wrap. - - GitHub Copilot OAuth `toAuth()` already returns per-credential request `baseUrl` for streaming. - - Wrap Copilot's provider `getModels()` when an OAuth credential is present so extension/UI-visible model metadata also carries the authenticated account base URL. - - Keep API-key/env-token Copilot behavior unchanged. - - Add tests for model metadata before login, after OAuth credential, after refresh/baseUrl change, and logout. -7. [x] Extension OAuth adapter. - - Keep only the legacy callback/credential declarations required by coding-agent `ProviderConfig.oauth`. - - `login` maps legacy callbacks/events to `AuthInteraction.prompt()`/`notify()`. - - `refreshToken` maps to `refresh`; `getApiKey` maps to `toAuth`. - - Preserve the type-only pi-ai `oauth` barrel and extension-loader aliases. -8. [ ] Rebuild coding-agent `ModelRegistry` over `MutableModels`. - - It owns a `MutableModels` instance built from decorated built-ins + models.json custom providers + extension providers. - - `getAll()`, `find()`, and `getAvailable()` remain sync compatibility methods over last-known model lists and fast configured-looking auth status. Do not break the extension-facing `modelRegistry` surface for these reads. - - `refresh()` is the explicit async freshness boundary: rebuild provider layers and call `models.refresh()` where needed; no global api-registry reset should be part of the new path except compat-only grace behavior. - - `registerProvider()`/`unregisterProvider()` mutate provider layers and rebuild the collection. - - Facade auth ops (`login`, `logout`, provider status, available OAuth providers) drive `provider.auth.{apiKey,oauth}` and the `CredentialStore`; no `AuthStorage` type remains. - - Legacy `registerApiProvider` writes stay only for `/compat` callers and are removed in Phase 10. -9. [ ] Rewire consumers. - - `AgentSession` stream function resolves through `ModelRegistry`/`Models`, not `getApiKeyAndHeaders()` + compat globals. - - SDK options replace `authStorage` with `credentials?: CredentialStore` or an agent-dir-backed default; update `sdk.md` and examples. - - `model-resolver`, `--list-models`, model selector, login/logout/status UI, and provider attribution use sync last-known model reads and await only explicit refresh/auth operations. - - CLI `--api-key` populates the runtime override decorator instead of mutating `AuthStorage`. - - Keep extension loader root-to-compat alias until Phase 10, but expose the new collection/facade as the forward API. -10. [ ] Test migration and real-provider validation. - - Unit tests for `FileCredentialStore`, runtime override overlay, provider decoration, extension OAuth adapter, Models-backed ModelRegistry facade, and consumer rewiring. - - Regression tests for Cloudflare account/gateway env, Copilot OAuth baseUrl wrapping, runtime `--api-key` precedence, `$ENV`/`!command` resolution, and stored credential blocking ambient fallback. - - Update existing tests for sync last-known `ModelRegistry.getAll/find/getAvailable` plus explicit async refresh behavior. - - Run targeted non-e2e suites plus tmux validation of login flows against real providers (Anthropic OAuth/API key, OpenAI Codex OAuth, GitHub Copilot OAuth, Cloudflare AI Gateway, Bedrock if credentials are available). - -### Phase 10 — compat deletion (pi 2.0 era, separate) - -- [ ] AgentSession -> AgentHarness; the registry facade dies in favor of harness `Models`. -- [ ] Move ALL internal `/compat` imports to the new API: every package's src, all tests, and the example extensions (examples then demonstrate the new API). Nothing inside the repo may import `/compat` at that point. -- [ ] Delete `/compat`, `env-api-keys.ts`, the extension-loader root-to-compat alias, and the compat-local legacy API registry. The old OAuth registry/provider interface is already gone; the type-only `oauth` barrel remains for extension compatibility. - -### Deferred / follow-ups - -- [ ] Web OAuth implementations (sitegeist-style) as an alternative `OAuthAuth`. -- [x] Images API redesign: `ImagesModels`/`ImagesProvider`/`createImagesProvider` mirror the chat-side design (sync reads, explicit refresh, never-reject generation); auth resolution shared with the chat side via the free-standing `resolveProviderAuth()` in `auth/resolve.ts` (which also owns `ModelsError`; both collections pass their store/context as arguments — no resolver object). `openrouterImagesProvider()` factory + `builtinImagesProviders()`/`builtinImagesModels()` in `providers/all`; impl moved to `api/openrouter-images.ts` with a lazy wrapper. The old global image API (registry + `getImageModel*` + `generateImages`) stays on compat; `ImagesProvider` id alias in types.ts renamed to `ImagesProviderId` (mirror of `Provider` -> `ProviderId`). - -## Error behavior - -`undefined` means not found or not configured. Real failures reject or become stream errors. - -```ts -export type ModelsErrorCode = - | "model_source" // provider model refresh failed - | "model_validation" // model object invalid - | "provider" // unknown provider, dispatch failure - | "stream" // stream setup failure - | "auth" // auth resolution failure - | "oauth"; // oauth login/refresh failure -``` - -- `Models.stream()` produces stream errors (error event + error result) for async setup failures; it does not throw after returning the stream. -- `Models.getModels()` is a sync best-effort read: a provider whose `getModels()` throws yields no models. `Models.refresh(provider)` rejects on that provider's fetch failure; `Models.refresh()` (all providers) is concurrent best-effort. Apps that need a concrete listing failure refresh the single provider. -- Auth resolution and credential store failures reject loudly (`ModelsError` codes `auth`/`oauth`); silent fallback to a different auth path after a failure risks billing surprises. A stored credential always blocks ambient/env fallback, including after a failed refresh. -- Status/availability UIs catch `getAuth` rejections and render "needs re-login"; they do not treat rejection as "unconfigured". diff --git a/packages/agent/docs/observability.md b/packages/agent/docs/observability.md deleted file mode 100644 index 2f77b3fca93..00000000000 --- a/packages/agent/docs/observability.md +++ /dev/null @@ -1,376 +0,0 @@ - - -# Pi Observability Design Notes - -## Goal - -Make `packages/ai` and `packages/agent`/harness observable without depending on OpenTelemetry, Sentry, or any APM vendor. - -Pi should emit stable, structured lifecycle events. External listeners can convert those events into OTel spans, Sentry spans, logs, metrics, or custom telemetry. - -## Mental model - -A trace is one causal tree of work, e.g. one user turn. - -A span is one timed operation in that tree. It is normally represented by IDs, not object pointers: - -```ts -interface SpanRecord { - traceId: string; - spanId: string; - parentSpanId?: string; - name: string; - startTime: number; - endTime?: number; - attributes: Record; - status: "ok" | "error"; -} -``` - -Example tree: - -```text -traceId=t1 spanId=s1 parent=- name=pi.agent.prompt -traceId=t1 spanId=s2 parent=s1 name=pi.agent.turn -traceId=t1 spanId=s3 parent=s2 name=pi.ai.provider.request -traceId=t1 spanId=s4 parent=s2 name=pi.agent.tool_call -traceId=t1 spanId=s5 parent=s4 name=pi.session.append_entry -``` - -## Async context - -JavaScript has one event loop but multiple async chains can interleave. A single global `currentContext` breaks under concurrency. - -`AsyncLocalStorage` is the Node equivalent of `ThreadLocal` for async continuations. It lets concurrent operations keep distinct current contexts: - -```ts -await Promise.all([ - runWithPiContext({ userId: "alice" }, () => harness.prompt("A")), - runWithPiContext({ userId: "bob" }, () => harness.prompt("B")), -]); -``` - -Deep code can then read the correct current context for the active async chain. - -Pi must run in Node, Bun, browser, workers, and other JS runtimes, so ALS cannot be the core abstraction. It should be a runtime adapter. - -## Core design - -Pi owns a small runtime-agnostic observability abstraction: - -```ts -export interface PiObservabilityContext { - traceId?: string; - currentSpanId?: string; - userContext?: Record; -} - -export interface PiObservabilityEvent { - type: "start" | "end" | "error" | "event"; - name: string; - traceId: string; - spanId?: string; - parentSpanId?: string; - timestamp: number; - durationMs?: number; - context?: Record; - payload?: Record; - error?: { name: string; message: string }; -} - -export interface PiObservability { - getContext(): PiObservabilityContext | undefined; - runWithContext(context: PiObservabilityContext, fn: () => T): T; - emit(event: PiObservabilityEvent): void; - hasSubscribers(): boolean; -} -``` - -Public API: - -```ts -export function configurePiObservability(observability: PiObservability): void; -export function subscribePiObservability(listener: (event: PiObservabilityEvent) => void): () => void; -export function runWithPiContext(userContext: Record, fn: () => T): T; -export function traceOperation(name: string, payload: Record, fn: () => T): T; -``` - -`traceOperation()`: - -1. reads the current context -2. creates `traceId` if missing -3. creates a new `spanId` -4. uses current span as `parentSpanId` -5. emits `start` -6. runs callback under child context -7. emits `end` or `error` -8. rethrows on error - -Pseudo-code: - -```ts -function traceOperation(name: string, payload: Record, fn: () => T): T { - const parent = getContext(); - const traceId = parent?.traceId ?? createId(); - const spanId = createId(); - const parentSpanId = parent?.currentSpanId; - - const child = { ...parent, traceId, currentSpanId: spanId }; - - emit({ type: "start", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: parent?.userContext, payload }); - - return runWithContext(child, () => { - try { - const result = fn(); - // Promise-aware implementation emits end/error after settlement. - emit({ type: "end", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload }); - return result; - } catch (error) { - emit({ type: "error", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload, error: serializeError(error) }); - throw error; - } - }); -} -``` - -## Runtime adapters - -Core packages should not import Node-only APIs. - -Possible implementations: - -- Node adapter: `AsyncLocalStorage` for context, optional `diagnostics_channel` publishing. -- Browser/workers fallback: local subscriber set and limited/manual context propagation. -- Bun/Deno adapters: use runtime-specific async context if available. - -For Node, diagnostics channels can be used as a passive event bus: - -```ts -import { channel } from "diagnostics_channel"; -channel("pi.observability").publish(event); -``` - -Subscribers can create OTel/Sentry spans without monkey-patching pi. - -## What pi emits - -Pi emits what happened. It does not create OTel/Sentry spans directly. - -Initial minimal event names: - -```text -pi.agent.prompt -pi.agent.skill -pi.agent.prompt_template -pi.agent.compaction -pi.agent.branch_navigation -pi.agent.session.append_entry -pi.ai.provider.request -``` - -Each operation emits: - -```text -start -end -error -``` - -Later additions: - -```text -pi.agent.turn -pi.agent.tool_call -pi.agent.queue_update -pi.ai.provider.retry -pi.ai.provider.first_token -pi.ai.provider.usage -pi.session.read -pi.session.write -``` - -## Minimal instrumentation points - -### packages/agent - -Wrap: - -- `AgentHarness.prompt()` -- `AgentHarness.skill()` -- `AgentHarness.promptFromTemplate()` -- `AgentHarness.compact()` -- `AgentHarness.navigateTree()` -- `Session.appendTypedEntry()` or storage append facade - -Example: - -```ts -return traceOperation( - "pi.agent.prompt", - { - sessionId: turnState.sessionId, - provider: turnState.model.provider, - model: turnState.model.id, - promptLength: text.length, - imageCount: options?.images?.length ?? 0, - }, - () => this.executeTurn(turnState, text, options), -); -``` - -Session write: - -```ts -return traceOperation( - "pi.agent.session.append_entry", - { entryType: entry.type }, - async () => { - await this.unwrap(this.storage.appendEntry(entry)); - return entry.id; - }, -); -``` - -### packages/ai - -Wrap common provider boundaries: - -- `streamSimple()` -- `completeSimple()` - -Example: - -```ts -return traceOperation( - "pi.ai.provider.request", - { - api: model.api, - provider: model.provider, - model: model.id, - sessionId: options.sessionId, - reasoning: options.reasoning, - }, - () => actualStreamSimple(model, context, options), -); -``` - -End/error payloads can include safe metadata: - -- stop reason -- status code -- retry count -- input/output/total tokens -- cost total -- aborted/timeout flag - -## Safety and redaction - -Default payloads must be safe. - -Safe by default: - -- provider -- model -- API identifier -- session id -- entry type -- tool name -- status code -- stop reason -- token counts -- costs -- durations - -Unsafe by default: - -- prompts -- completions -- tool args -- tool results -- shell output -- file contents -- provider request payloads -- provider response bodies -- API keys -- headers - -Content capture can be opt-in later with explicit redaction hooks. - -## Listener behavior - -Observability must never affect pi execution. - -Subscriber errors should be swallowed or isolated. Harness hooks are control-plane and may affect execution; observability subscribers are passive and must not. - -## User context - -Users can associate arbitrary context with a turn: - -```ts -await runWithPiContext( - { - userId: "u123", - orgId: "acme", - region: "eu", - }, - () => harness.prompt("fix this"), -); -``` - -Every emitted event inside that async chain includes the context: - -```ts -{ - type: "start", - name: "pi.ai.provider.request", - traceId: "t1", - spanId: "s3", - parentSpanId: "s1", - context: { - userId: "u123", - orgId: "acme", - region: "eu", - }, - payload: { - provider: "anthropic", - model: "claude-sonnet-4", - }, -} -``` - -An OTel adapter can map this to span attributes. A Sentry adapter can map it to Sentry context/spans. A custom user can log JSON. - -## Package story - -Minimal initial package: - -```text -packages/observability - runtime-agnostic context + traceOperation + subscribe -``` - -Then: - -```text -packages/ai - emits pi.ai.* events - -packages/agent - emits pi.agent.* / pi.session.* events -``` - -Optional later: - -```text -packages/observability-node - AsyncLocalStorage + diagnostics_channel bridge - -packages/otel - subscribes to pi events and creates OpenTelemetry spans -``` - -## Thesis - -Pi defines a stable, safe event contract. Adapters define where events go. - -This makes ai/harness observable without binding core packages to OTel, Sentry, Node-only APIs, or monkey-patching. diff --git a/packages/agent/package.json b/packages/agent/package.json index b4595920056..8df867cb643 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -14,13 +14,9 @@ "types": "./dist/node.d.ts", "import": "./dist/node.js" }, - "./experimental": { - "types": "./dist/experimental.d.ts", - "import": "./dist/experimental.js" - }, - "./experimental/session/testing": { - "types": "./dist/harness/experimental/session/testing/index.d.ts", - "import": "./dist/harness/experimental/session/testing/index.js" + "./session/testing": { + "types": "./dist/harness/session/testing/index.d.ts", + "import": "./dist/harness/session/testing/index.js" }, "./package.json": "./package.json" }, diff --git a/packages/agent/src/experimental.ts b/packages/agent/src/experimental.ts deleted file mode 100644 index 8639f4073af..00000000000 --- a/packages/agent/src/experimental.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./harness/experimental/session/index.ts"; diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 50c69633091..5cd19db8016 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -1,1185 +1,532 @@ -import { - type AssistantMessage, - contentText, - type ImageContent, - type Model, - type Models, - type RetryCallbacks, - type RetryPolicy, - type UserMessage, -} from "@cheetahbyte/pi-ai"; -import { runAgentLoop } from "../agent-loop.ts"; import type { - AgentContext, - AgentEvent, - AgentLoopConfig, - AgentMessage, - AgentTool, - QueueMode, - StreamFn, - ThinkingLevel, -} from "../types.ts"; -import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.ts"; -import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.ts"; -import { convertToLlm } from "./messages.ts"; -import { formatPromptTemplateInvocation } from "./prompt-templates.ts"; -import { formatSkillInvocation } from "./skills.ts"; + Api, + AssistantMessage, + DeferredHandle, + ImageContent, + Message, + Model, + Models, + RetryPolicy, + SimpleStreamOptions, + Usage, +} from "@cheetahbyte/pi-ai"; +import type { AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts"; +import type { CompactionSettings } from "./compaction/compaction.ts"; +import { type Result as ResultValue, TaggedError } from "./result.ts"; import type { - AbortResult, - AgentHarnessEvent, - AgentHarnessEventResultMap, - AgentHarnessOptions, - AgentHarnessOwnEvent, - AgentHarnessPhase, - AgentHarnessResources, - AgentHarnessStreamOptions, - AgentHarnessStreamOptionsPatch, - AgentHarnessSystemPrompt, - AgentHarnessTool, - AgentHarnessToolContextSource, - CompactResult, - NavigateTreeResult, - PendingSessionWrite, - PromptTemplate, + BranchSummaryEntry, + CompactionEntry, + Entry, + JsonValue, + ProvisionedEntry, Session, - Skill, -} from "./types.ts"; -import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError, toError } from "./types.ts"; + SessionTree, +} from "./session/index.ts"; +import type { AgentHarnessResources, PromptTemplate, Skill } from "./types.ts"; + +export class LaneBusy extends TaggedError("LaneBusy")<{ + lane: string; + operationId: string; + operationKind: "run" | "compaction" | "navigation"; + message: string; +}> {} +export class MissingIdentities extends TaggedError("MissingIdentities")<{ + lane: string; + tools: string[]; + models: string[]; + message: string; +}> {} +export class NoActiveRun extends TaggedError("NoActiveRun")<{ lane: string; message: string }> {} +export class NoActiveOperation extends TaggedError("NoActiveOperation")<{ lane: string; message: string }> {} +export class NothingToResume extends TaggedError("NothingToResume")<{ lane: string; message: string }> {} +export class InvalidMessage extends TaggedError("InvalidMessage")<{ lane: string; reason: string; message: string }> {} +export class UnknownSkill extends TaggedError("UnknownSkill")<{ name: string; message: string }> {} +export class UnknownTemplate extends TaggedError("UnknownTemplate")<{ name: string; message: string }> {} +export class UnknownTarget extends TaggedError("UnknownTarget")<{ targetId: string; message: string }> {} +export class UnknownQueueItem extends TaggedError("UnknownQueueItem")<{ + lane: string; + entryId: string; + message: string; +}> {} +export class LaneExists extends TaggedError("LaneExists")<{ lane: string; message: string }> {} +export class InvalidLane extends TaggedError("InvalidLane")<{ lane: string; reason: string; message: string }> {} +export class NothingToCompact extends TaggedError("NothingToCompact")<{ lane: string; message: string }> {} +export class Closed extends TaggedError("Closed")<{ message: string }> {} + +export class HarnessFault extends Error { + readonly cause: unknown; + + constructor(message: string, cause: unknown) { + super(message); + this.name = "HarnessFault"; + this.cause = cause; + } +} -function createUserMessage(text: string, images?: ImageContent[]): UserMessage { - const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; - if (images) content.push(...images); - return { role: "user", content, timestamp: Date.now() }; +export class HarnessClosed extends Error { + constructor() { + super("AgentHarness was closed while the operation was active"); + this.name = "HarnessClosed"; + } } -function createFailureMessage(model: Model, error: unknown, aborted: boolean): AssistantMessage { - return { - role: "assistant", - content: [{ type: "text", text: "" }], - api: model.api, - provider: model.provider, - model: model.id, - stopReason: aborted ? "aborted" : "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }; +export class HarnessNotImplemented extends Error { + readonly operation: string; + + constructor(operation: string) { + super(`AgentHarness.${operation} is not implemented yet`); + this.name = "HarnessNotImplemented"; + this.operation = operation; + } } -function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { - return { - ...streamOptions, - headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined, - metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined, - }; +export interface OperationError { + code: string; + message: string; } -function findDuplicateNames(names: string[]): string[] { - const seen = new Set(); - const duplicates = new Set(); - for (const name of names) { - if (seen.has(name)) duplicates.add(name); - seen.add(name); - } - return [...duplicates]; +export type RunOutcome = + | { kind: "completed"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } + | { kind: "aborted"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } + | { kind: "failed"; leafId: string; error: OperationError; finalEntryId?: string; finalMessage?: AssistantMessage } + | { kind: "suspended"; leafId: string; finalEntryId: string; deferred: DeferredHandle }; + +export type CompactionOutcome = + | { kind: "completed"; leafId: string; entry: CompactionEntry } + | { kind: "declined" | "aborted"; leafId: string } + | { kind: "failed"; leafId: string; error: OperationError }; + +export type NavigationOutcome = + | { kind: "completed"; newLeafId: string | null; summaryEntry?: BranchSummaryEntry } + | { kind: "declined" | "aborted"; leafId: string | null } + | { kind: "failed"; leafId: string | null; error: OperationError }; + +export type RunRejected = LaneBusy | InvalidMessage | UnknownSkill | UnknownTemplate | Closed; +export type CompactionRejected = LaneBusy | NothingToCompact | Closed; +export type NavigationRejected = LaneBusy | UnknownTarget | Closed; +export type ResumeRejected = LaneBusy | NothingToResume | MissingIdentities | Closed; +export type QueueRejected = NoActiveRun | InvalidMessage | Closed; +export type CancelQueuedRejected = UnknownQueueItem | Closed; +export type AbortRejected = NoActiveOperation | Closed; + +export type RunResult = ResultValue<{ runId: string } & RunOutcome, RunRejected>; +export type CompactionResult = ResultValue<{ runId: string } & CompactionOutcome, CompactionRejected>; +export type NavigationResult = ResultValue<{ runId: string } & NavigationOutcome, NavigationRejected>; +export type QueueResult = ResultValue<{ entryId: string }, QueueRejected>; +export type CancelQueuedResult = ResultValue< + { outcome: "cancelled" | "already_consumed" | "already_cleared" }, + CancelQueuedRejected +>; +export type RecordUsageResult = ResultValue; +export type AbortResult = ResultValue< + { runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, + AbortRejected +>; + +export type ResumeOutcome = + | ({ operation: "run"; runId: string } & RunOutcome) + | ({ operation: "compaction"; runId: string } & CompactionOutcome) + | ({ operation: "navigation"; runId: string } & NavigationOutcome); +export type ResumeResult = ResultValue; +export type CreateLaneResult = ResultValue; + +export interface NavigateOptions { + summarize?: boolean; + customInstructions?: string; + label?: string; } -function applyStreamOptionsPatch( - base: AgentHarnessStreamOptions, - patch?: AgentHarnessStreamOptionsPatch, -): AgentHarnessStreamOptions { - const result = cloneStreamOptions(base); - if (!patch) return result; +export interface SuspendedOperation { + lane: string; + kind: "run" | "compaction" | "navigation"; + id: string; + startedAt: number; + reason: "crash" | "deferred"; + prompt?: AgentMessage[]; + deferred?: DeferredHandle; + aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; + missing: { tools: string[]; models: string[] }; +} - if (Object.hasOwn(patch, "transport")) result.transport = patch.transport; - if (Object.hasOwn(patch, "timeoutMs")) result.timeoutMs = patch.timeoutMs; - if (Object.hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries; - if (Object.hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs; - if (Object.hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention; +export interface LaneInfo { + name: string; + leafId: string | null; + operation: null | { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + }; +} - if (Object.hasOwn(patch, "headers")) { - if (patch.headers === undefined) { - result.headers = undefined; - } else { - const headers = { ...(result.headers ?? {}) }; - for (const [key, value] of Object.entries(patch.headers)) { - if (value === undefined) delete headers[key]; - else headers[key] = value; - } - result.headers = Object.keys(headers).length > 0 ? headers : undefined; - } - } +export interface QueuedItem { + entryId: string; + message: AgentMessage; +} - if (Object.hasOwn(patch, "metadata")) { - if (patch.metadata === undefined) { - result.metadata = undefined; - } else { - const metadata = { ...(result.metadata ?? {}) }; - for (const [key, value] of Object.entries(patch.metadata)) { - if (value === undefined) delete metadata[key]; - else metadata[key] = value; - } - result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined; - } - } +export interface LaneSnapshot { + lane: string; + transcript: Entry[]; + leafId: string | null; + operation: LaneInfo["operation"]; + queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; + pendingWrites: { id: string; entry: ProvisionedEntry }[]; + faulted: boolean; +} - return result; +export interface SessionSnapshot { + lanes: (LaneInfo & { suspended?: SuspendedOperation })[]; + faulted: boolean; } -const SUBSCRIBER_EVENT_TYPE = "*"; +export type ActionInfo = + | { kind: "append_entry"; entryType: Entry["type"]; entryId: string } + | { kind: "append_record"; recordType: string } + | { kind: "move_lane"; to: string | null } + | { kind: "set_fact"; fact: "name" | "label" } + | { kind: "try_finish_run"; outcome: "completed" | "failed" } + | { kind: "finish_operation"; outcome: "completed" | "declined" | "failed" | "aborted" } + | { kind: "commit_follow_up" } + | { kind: "consume_queue_item"; queue: "steer" | "followUp"; entryId: string } + | { kind: "apply_pending_write"; entryId: string } + | { kind: "stream_assistant"; step: "assistant" | "compaction" | "branch_summary"; attempt: number } + | { kind: "execute_tool"; toolCallId: string; toolName: string } + | { kind: "fetch_deferred" | "cancel_deferred"; provider: string; id: string } + | { kind: "hook"; name: HookName } + | { kind: "sleep"; delayMs: number }; + +export type HookName = + | "before_run" + | "before_resume" + | "before_run_end" + | "transform_context" + | "before_request" + | "before_payload" + | "after_response" + | "before_tool" + | "after_tool" + | "before_compaction" + | "before_navigation"; + +export interface Hooks { + on(name: HookName, handler: (event: unknown) => unknown | Promise, options?: { id?: string }): () => void; +} -type AgentHarnessHandler = (event: any, signal?: AbortSignal) => Promise | any; +export interface Events { + on(type: string, listener: (event: unknown) => void | Promise): () => void; +} -type TrackedTaskKind = "operation" | "mutation"; +class PassiveRegistry implements Hooks, Events { + on( + _name: HookName | string, + _handler: (event: unknown) => unknown | Promise, + _options?: { id?: string }, + ): () => void { + return () => {}; + } +} -function normalizeHarnessError(error: unknown, fallbackCode: AgentHarnessError["code"]): AgentHarnessError { - if (error instanceof AgentHarnessError) return error; - const cause = toError(error); - if (cause instanceof SessionError) return new AgentHarnessError("session", cause.message, cause); - if (cause instanceof CompactionError) return new AgentHarnessError("compaction", cause.message, cause); - if (cause instanceof BranchSummaryError) return new AgentHarnessError("branch_summary", cause.message, cause); - return new AgentHarnessError(fallbackCode, cause.message, cause); +export interface ExecutionSpan extends ExecutionContext { + addEvent(name: string, attributes?: SpanAttributes): void; + setAttributes(attributes: SpanAttributes): void; + end(result: SpanEnd): void; +} + +export interface ExecutionContext { + startSpan(name: string, attributes?: SpanAttributes): ExecutionSpan; +} +export interface SpanAttributes { + [name: string]: string | number | boolean | undefined; +} +export interface SpanEnd { + status: "ok" | "error"; + error?: { name: string; message: string }; + attributes?: SpanAttributes; } -function normalizeHookError(error: unknown): AgentHarnessError { - return normalizeHarnessError(error, "hook"); +export type HarnessTool = AgentTool & { replay?: "never" | "safe" }; +export type Resources = AgentHarnessResources; +export type StreamOptions = SimpleStreamOptions; +export type StreamOptionsPatch = Partial; +export type EntryProjector = (entry: Entry) => AgentMessage[] | Promise; + +export interface AgentHarnessOptions { + session: Session; + models: Models; + model: Model; + thinkingLevel?: ThinkingLevel; + activeToolNames?: string[]; + tools?: HarnessTool[]; + toolContext?: object | (() => object | Promise); + systemPrompt?: string | (() => string | Promise); + resources?: Resources; + streamOptions?: StreamOptions; + retry?: RetryPolicy; + compaction?: CompactionSettings; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + toolExecution?: "sequential" | "parallel"; + drive?: "automatic" | "manual"; + toProviderMessages?: (messages: AgentMessage[]) => Message[] | Promise; + entryProjectors?: Record; + context?: ExecutionContext; } -interface AgentHarnessTurnState< - TContext extends object | undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> { - messages: AgentMessage[]; - resources: AgentHarnessResources; - toolContext: TContext; - streamOptions: AgentHarnessStreamOptions; - sessionId: string; - systemPrompt: string; - model: Model; - thinkingLevel: ThinkingLevel; - tools: TTool[]; - activeTools: TTool[]; +export interface WatchHandle { + snapshot: TSnapshot; + start(listener: (event: unknown) => void): void; + unsubscribe(): void; } -export class AgentHarness< - TContext extends object | undefined = undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> { - private session: Session; - readonly models: Models; - private phase: AgentHarnessPhase = "idle"; - private activeAbortController?: AbortController; - private readonly activeTasks = new Map, TrackedTaskKind>(); - private shutdownPromise?: Promise; - private isShutdown = false; - private pendingSessionWrites: PendingSessionWrite[] = []; - private model: Model; +export interface AgentLane { + readonly name: string; + getLeafId(): Promise; + prompt(text: string, images?: ImageContent[]): Promise; + prompt(message: AgentMessage | AgentMessage[]): Promise; + skill(name: string, additionalInstructions?: string): Promise; + promptFromTemplate(name: string, args?: string[]): Promise; + compact(options?: { customInstructions?: string }): Promise; + navigateTree(targetId: string | null, options?: NavigateOptions): Promise; + resume(): Promise; + abort(): Promise; + steer(text: string, images?: ImageContent[]): Promise; + steer(message: AgentMessage): Promise; + followUp(text: string, images?: ImageContent[]): Promise; + followUp(message: AgentMessage): Promise; + nextRun(text: string, images?: ImageContent[]): Promise; + nextRun(message: AgentMessage): Promise; + cancelQueued(entryId: string): Promise; + recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): Promise; + waitForIdle(): Promise; + runWhenIdle(callback: () => void | Promise): Promise; + peekAction(): Promise; + executeAction(): Promise; + runToCompletion(): Promise; + getModel(): Promise>; + setModel(model: Model): Promise; + getThinkingLevel(): Promise; + setThinkingLevel(level: ThinkingLevel): Promise; + getActiveTools(): Promise; + setActiveTools(names: string[]): Promise; + readonly session: SessionTree; + watch(): Promise>; +} + +export class AgentHarness implements AgentLane { + readonly name = "main"; + readonly session: SessionTree; + readonly hooks: Hooks = new PassiveRegistry(); + readonly events: Events = new PassiveRegistry(); + private readonly durableSession: Session; + private model: Model; private thinkingLevel: ThinkingLevel; - private systemPrompt: AgentHarnessSystemPrompt | undefined; - private toolContext: AgentHarnessToolContextSource | undefined; - private streamOptions: AgentHarnessStreamOptions; - private retry: RetryPolicy | undefined; - private resources: AgentHarnessResources; - private tools = new Map(); private activeToolNames: string[]; - private steerQueue: UserMessage[] = []; - private steeringQueueMode: QueueMode; - private followUpQueue: UserMessage[] = []; - private followUpQueueMode: QueueMode; - private nextTurnQueue: AgentMessage[] = []; - private handlers = new Map>(); - - constructor(options: AgentHarnessOptions) { + private tools: HarnessTool[]; + private resources: Resources; + private streamOptions: StreamOptions; + private retryPolicy: RetryPolicy; + private compactionSettings: CompactionSettings; + private steeringMode: QueueMode; + private followUpMode: QueueMode; + private closed = false; + + private constructor(options: AgentHarnessOptions) { + this.durableSession = options.session; this.session = options.session; - this.models = options.models; - this.resources = options.resources ?? {}; - this.streamOptions = cloneStreamOptions(options.streamOptions); - this.retry = options.retry; - this.systemPrompt = options.systemPrompt; - this.toolContext = options.toolContext; - this.validateUniqueNames( - (options.tools ?? []).map((tool) => tool.name), - "Duplicate tool name(s)", - ); - for (const tool of options.tools ?? []) { - this.tools.set(tool.name, tool); - } this.model = options.model; this.thinkingLevel = options.thinkingLevel ?? "off"; - this.activeToolNames = options.activeToolNames - ? [...options.activeToolNames] - : (options.tools ?? []).map((tool) => tool.name); - this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)"); - this.validateToolNames(this.activeToolNames); - this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; - this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; - } - - private assertNotShutDown(): void { - if (this.isShutdown) throw new AgentHarnessError("invalid_state", "AgentHarness has been shut down"); - } - - private getHandlers(type: string): Set | undefined { - return this.handlers.get(type); - } - - private async emitOwn(event: AgentHarnessOwnEvent, signal?: AbortSignal): Promise { - for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { - try { - await listener(event, signal); - } catch (error) { - throw normalizeHookError(error); - } - } - } - - private async emitAny(event: AgentHarnessEvent, signal?: AbortSignal): Promise { - for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { - try { - await listener(event, signal); - } catch (error) { - throw normalizeHookError(error); - } - } - } - - private async emitHook( - event: Extract, - ): Promise { - const handlers = this.getHandlers(event.type as TType); - if (!handlers || handlers.size === 0) return undefined; - let lastResult: AgentHarnessEventResultMap[TType] | undefined; - for (const handler of handlers) { - try { - const result = await handler(event); - if (result !== undefined) { - lastResult = result; - } - } catch (error) { - throw normalizeHookError(error); - } - } - return lastResult; - } - - private retryCallbacks(operation: "compaction" | "branch_summary"): RetryCallbacks { - return { - onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => - this.emitOwn({ type: "retry_scheduled", operation, attempt, maxAttempts, delayMs, errorMessage }), - onRetryAttemptStart: () => this.emitOwn({ type: "retry_attempt_start", operation }), - onRetryFinished: () => this.emitOwn({ type: "retry_finished", operation }), - }; - } - - private async emitBeforeProviderRequest( - model: Model, - sessionId: string, - streamOptions: AgentHarnessStreamOptions, - ): Promise { - const handlers = this.getHandlers("before_provider_request"); - let current = cloneStreamOptions(streamOptions); - if (!handlers || handlers.size === 0) return current; - for (const handler of handlers) { - try { - const result = await handler({ - type: "before_provider_request", - model, - sessionId, - streamOptions: cloneStreamOptions(current), - }); - if (result?.streamOptions) { - current = applyStreamOptionsPatch(current, result.streamOptions); - } - } catch (error) { - throw normalizeHookError(error); - } - } - return current; - } - - private async emitBeforeProviderPayload(model: Model, payload: unknown): Promise { - const handlers = this.getHandlers("before_provider_payload"); - let current = payload; - if (!handlers || handlers.size === 0) return current; - for (const handler of handlers) { - try { - const result = await handler({ type: "before_provider_payload", model, payload: current }); - if (result !== undefined) { - current = result.payload; - } - } catch (error) { - throw normalizeHookError(error); - } - } - return current; - } - - private async emitQueueUpdate(): Promise { - await this.emitOwn({ - type: "queue_update", - steer: [...this.steerQueue], - followUp: [...this.followUpQueue], - nextTurn: [...this.nextTurnQueue], - }); - } - - private startOperation(): { signal: AbortSignal; finish: () => void } { - const abortController = new AbortController(); - let finish = () => {}; - this.activeAbortController = abortController; - void this.track( - "operation", - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - return { - signal: abortController.signal, - finish: () => { - this.activeAbortController = undefined; - finish(); - }, - }; - } - - private async track(kind: TrackedTaskKind, operation: () => Promise): Promise { - let settle = () => {}; - const settled = new Promise((resolve) => { - settle = resolve; - }); - this.activeTasks.set(settled, kind); - try { - return await operation(); - } finally { - this.activeTasks.delete(settled); - settle(); - } - } - - private async waitForTasks(kind?: TrackedTaskKind): Promise { - while (true) { - const tasks = [...this.activeTasks].flatMap(([task, taskKind]) => - kind === undefined || kind === taskKind ? [task] : [], - ); - if (tasks.length === 0) return; - await Promise.all(tasks); - } - } - - private async resolveToolContext(): Promise { - if (typeof this.toolContext === "function") { - return await (this.toolContext as () => TContext | Promise)(); - } - return this.toolContext as TContext; - } - - private bindToolContext(tool: TTool, context: TContext): AgentTool { - return { - ...tool, - execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate, context), - }; - } - - private async createTurnState(): Promise> { - this.assertNotShutDown(); - const context = await this.session.buildContext(); - const resources = this.getResources(); - const sessionMetadata = await this.session.getMetadata(); - const toolContext = await this.resolveToolContext(); - const tools = [...this.tools.values()]; - const activeTools = this.activeToolNames - .map((name) => this.tools.get(name)) - .filter((tool): tool is TTool => tool !== undefined); - let systemPrompt = "You are a helpful assistant."; - if (typeof this.systemPrompt === "string") { - systemPrompt = this.systemPrompt; - } else if (this.systemPrompt) { - systemPrompt = await this.systemPrompt({ - session: this.session, - model: this.model, - thinkingLevel: this.thinkingLevel, - activeTools, - resources, - }); - } - return { - messages: context.messages, - resources, - toolContext, - streamOptions: cloneStreamOptions(this.streamOptions), - sessionId: sessionMetadata.id, - systemPrompt, - model: this.model, - thinkingLevel: this.thinkingLevel, - tools, - activeTools, + this.activeToolNames = [...(options.activeToolNames ?? options.tools?.map((tool) => tool.name) ?? [])]; + this.tools = [...(options.tools ?? [])]; + this.resources = { + skills: options.resources?.skills ? [...options.resources.skills] : undefined, + promptTemplates: options.resources?.promptTemplates ? [...options.resources.promptTemplates] : undefined, }; - } - - private createContext( - turnState: AgentHarnessTurnState, - systemPrompt?: string, - ): AgentContext { - return { - systemPrompt: systemPrompt ?? turnState.systemPrompt, - messages: turnState.messages.slice(), - tools: turnState.activeTools.map((tool) => this.bindToolContext(tool, turnState.toolContext)), + this.streamOptions = { ...(options.streamOptions ?? {}) }; + this.retryPolicy = options.retry ?? { enabled: false, maxRetries: 0, baseDelayMs: 1000 }; + this.compactionSettings = options.compaction ?? { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, }; + this.steeringMode = options.steeringMode ?? "one-at-a-time"; + this.followUpMode = options.followUpMode ?? "one-at-a-time"; } - private createStreamFn( - getTurnState: () => AgentHarnessTurnState, - ): StreamFn { - return async (model, context, streamOptions) => { - const turnState = getTurnState(); - const snapshotOptions: AgentHarnessStreamOptions = { ...turnState.streamOptions }; - const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions); - return this.models.streamSimple(model, context, { - cacheRetention: requestOptions.cacheRetention, - headers: requestOptions.headers, - maxRetries: requestOptions.maxRetries, - maxRetryDelayMs: requestOptions.maxRetryDelayMs, - metadata: requestOptions.metadata, - onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload), - onResponse: async (response) => { - const headers = { ...(response.headers as Record) }; - await this.emitOwn( - { type: "after_provider_response", status: response.status, headers }, - streamOptions?.signal, - ); - }, - reasoning: streamOptions?.reasoning, - signal: streamOptions?.signal, - sessionId: turnState.sessionId, - timeoutMs: requestOptions.timeoutMs, - transport: requestOptions.transport, - }); - }; + static async create( + options: AgentHarnessOptions, + ): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[] }> { + return { harness: new AgentHarness(options), suspended: [] }; } - private async drainQueuedMessages(queue: AgentMessage[], mode: QueueMode): Promise { - const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1); - if (messages.length === 0) return messages; - try { - await this.emitQueueUpdate(); - return messages; - } catch (error) { - queue.unshift(...messages); - throw normalizeHookError(error); - } + private unavailable(operation: string): Promise { + return Promise.reject(this.closed ? new HarnessClosed() : new HarnessNotImplemented(operation)); } - private createLoopConfig( - getTurnState: () => AgentHarnessTurnState, - setTurnState: (turnState: AgentHarnessTurnState) => void, - ): AgentLoopConfig { - const turnState = getTurnState(); - return { - model: turnState.model, - reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel, - convertToLlm, - transformContext: async (messages) => { - const result = await this.emitHook({ type: "context", messages: [...messages] }); - return result?.messages ?? messages; - }, - beforeToolCall: async ({ toolCall, args }) => { - const result = await this.emitHook({ - type: "tool_call", - toolCallId: toolCall.id, - toolName: toolCall.name, - input: args as Record, - }); - return result ? { block: result.block, reason: result.reason } : undefined; - }, - afterToolCall: async ({ toolCall, args, result, isError }) => { - const patch = await this.emitHook({ - type: "tool_result", - toolCallId: toolCall.id, - toolName: toolCall.name, - input: args as Record, - content: result.content, - details: result.details, - isError, - usage: result.usage, - }); - return patch - ? { - content: patch.content, - details: patch.details, - isError: patch.isError, - usage: patch.usage, - terminate: patch.terminate, - } - : undefined; - }, - prepareNextTurn: async () => { - await this.flushPendingSessionWrites(); - const nextTurnState = await this.createTurnState(); - setTurnState(nextTurnState); - return { - context: this.createContext(nextTurnState), - model: nextTurnState.model, - thinkingLevel: nextTurnState.thinkingLevel, - }; - }, - getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode), - getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode), - }; + async getLeafId(): Promise { + return this.durableSession.getLeafId(); } - private validateUniqueNames(names: string[], message: string): void { - const duplicates = findDuplicateNames(names); - if (duplicates.length > 0) - throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`); + async prompt(_text: string, _images?: ImageContent[]): Promise; + async prompt(_message: AgentMessage | AgentMessage[]): Promise; + async prompt(_input: string | AgentMessage | AgentMessage[], _images?: ImageContent[]): Promise { + return this.unavailable("prompt"); } - - private validateToolNames(toolNames: string[], tools: Map = this.tools): void { - this.validateUniqueNames(toolNames, "Duplicate active tool name(s)"); - const missing = toolNames.filter((name) => !tools.has(name)); - if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); + async skill(_name: string, _additionalInstructions?: string): Promise { + return this.unavailable("skill"); } - - private async flushPendingSessionWrites(): Promise { - while (this.pendingSessionWrites.length > 0) { - const write = this.pendingSessionWrites[0]!; - if (write.type === "message") { - await this.session.appendMessage(write.message); - } else if (write.type === "model_change") { - await this.session.appendModelChange(write.provider, write.modelId); - } else if (write.type === "thinking_level_change") { - await this.session.appendThinkingLevelChange(write.thinkingLevel); - } else if (write.type === "active_tools_change") { - await this.session.appendActiveToolsChange(write.activeToolNames); - } else if (write.type === "custom") { - await this.session.appendCustomEntry(write.customType, write.data); - } else if (write.type === "custom_message") { - await this.session.appendCustomMessageEntry(write.customType, write.content, write.display, write.details); - } else if (write.type === "label") { - await this.session.appendLabel(write.targetId, write.label); - } else if (write.type === "session_info") { - await this.session.appendSessionName(write.name ?? ""); - } else if (write.type === "leaf") { - await this.session.moveTo(write.targetId); - } - this.pendingSessionWrites.shift(); - } + async promptFromTemplate(_name: string, _args?: string[]): Promise { + return this.unavailable("promptFromTemplate"); } - - private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise { - if (event.type === "message_end") { - await this.session.appendMessage(event.message); - await this.emitAny(event, signal); - return; - } - if (event.type === "turn_end") { - let eventError: unknown; - try { - await this.emitAny(event, signal); - } catch (error) { - eventError = error; - } - const hadPendingMutations = this.pendingSessionWrites.length > 0; - await this.flushPendingSessionWrites(); - if (eventError) throw eventError; - await this.emitOwn({ type: "save_point", hadPendingMutations }); - return; - } - if (event.type === "agent_end") { - await this.flushPendingSessionWrites(); - this.phase = "idle"; - await this.emitAny(event, signal); - await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal); - return; - } - await this.emitAny(event, signal); + async compact(_options?: { customInstructions?: string }): Promise { + return this.unavailable("compact"); } - - private async emitRunFailure( - model: Model, - error: unknown, - aborted: boolean, - signal: AbortSignal, - ): Promise { - const failureMessage = createFailureMessage(model, error, aborted); - await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal); - await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal); - await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal); - await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); - return [failureMessage]; + async navigateTree(_targetId: string | null, _options?: NavigateOptions): Promise { + return this.unavailable("navigateTree"); } - - private async executeTurn( - turnState: AgentHarnessTurnState, - text: string, - signal: AbortSignal, - options?: { images?: ImageContent[] }, - ): Promise { - this.assertNotShutDown(); - let activeTurnState = turnState; - let messages: AgentMessage[] = [createUserMessage(text, options?.images)]; - if (this.nextTurnQueue.length > 0) { - const queuedMessages = this.nextTurnQueue.splice(0); - try { - await this.emitQueueUpdate(); - } catch (error) { - this.nextTurnQueue.unshift(...queuedMessages); - throw normalizeHookError(error); - } - messages = [...queuedMessages, messages[0]!]; - } - const beforeResult = await this.emitHook({ - type: "before_agent_start", - prompt: text, - images: options?.images, - systemPrompt: turnState.systemPrompt, - resources: turnState.resources, - }); - this.assertNotShutDown(); - if (beforeResult?.messages) messages = [...messages, ...beforeResult.messages]; - - const getTurnState = () => activeTurnState; - const setTurnState = (nextTurnState: AgentHarnessTurnState) => { - activeTurnState = nextTurnState; - }; - const runResultPromise = (async () => { - try { - return await runAgentLoop( - messages, - this.createContext(turnState, beforeResult?.systemPrompt), - this.createLoopConfig(getTurnState, setTurnState), - (event) => this.handleAgentEvent(event, signal), - signal, - this.createStreamFn(getTurnState), - ); - } catch (error) { - try { - return await this.emitRunFailure(activeTurnState.model, error, signal.aborted, signal); - } catch (failureError) { - const cause = new AggregateError( - [toError(error), toError(failureError)], - "Agent run failed and failure reporting failed", - ); - throw new AgentHarnessError("unknown", cause.message, cause); - } - } - })(); - try { - const newMessages = await runResultPromise; - for (let i = newMessages.length - 1; i >= 0; i--) { - const message = newMessages[i]!; - if (message.role === "assistant") { - return message; - } - } - throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message"); - } finally { - await this.flushPendingSessionWrites(); - } + async resume(): Promise { + return this.unavailable("resume"); } - - async prompt(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); - this.phase = "turn"; - const operation = this.startOperation(); - try { - const turnState = await this.createTurnState(); - return await this.executeTurn(turnState, text, operation.signal, options); - } catch (error) { - this.phase = "idle"; - throw normalizeHarnessError(error, "unknown"); - } finally { - operation.finish(); - } + async abort(): Promise { + return this.unavailable("abort"); } - - async skill(name: string, additionalInstructions?: string): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); - this.phase = "turn"; - const operation = this.startOperation(); - try { - const turnState = await this.createTurnState(); - const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name); - if (!skill) throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`); - return await this.executeTurn( - turnState, - formatSkillInvocation(skill, additionalInstructions), - operation.signal, - ); - } catch (error) { - this.phase = "idle"; - throw normalizeHarnessError(error, "unknown"); - } finally { - operation.finish(); - } + async steer(_text: string, _images?: ImageContent[]): Promise; + async steer(_message: AgentMessage): Promise; + async steer(_input: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unavailable("steer"); } - - async promptFromTemplate(name: string, args: string[] = []): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); - this.phase = "turn"; - const operation = this.startOperation(); - try { - const turnState = await this.createTurnState(); - const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name); - if (!template) throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`); - return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args), operation.signal); - } catch (error) { - this.phase = "idle"; - throw normalizeHarnessError(error, "unknown"); - } finally { - operation.finish(); - } + async followUp(_text: string, _images?: ImageContent[]): Promise; + async followUp(_message: AgentMessage): Promise; + async followUp(_input: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unavailable("followUp"); } - - async steer(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot steer while idle"); - this.steerQueue.push(createUserMessage(text, options?.images)); - await this.emitQueueUpdate(); + async nextRun(_text: string, _images?: ImageContent[]): Promise; + async nextRun(_message: AgentMessage): Promise; + async nextRun(_input: string | AgentMessage, _images?: ImageContent[]): Promise { + return this.unavailable("nextRun"); } - - async followUp(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot follow up while idle"); - this.followUpQueue.push(createUserMessage(text, options?.images)); - await this.emitQueueUpdate(); + async cancelQueued(_entryId: string): Promise { + return this.unavailable("cancelQueued"); } - - async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise { - this.assertNotShutDown(); - this.nextTurnQueue.push(createUserMessage(text, options?.images)); - await this.emitQueueUpdate(); + async recordUsage(_usage: Usage, _options?: { entryId?: string; details?: JsonValue }): Promise { + return this.unavailable("recordUsage"); } - - async appendMessage(message: AgentMessage): Promise { - this.assertNotShutDown(); - return this.track("mutation", async () => { - try { - if (this.phase === "idle") { - await this.session.appendMessage(message); - } else { - this.pendingSessionWrites.push({ type: "message", message }); - } - } catch (error) { - throw normalizeHarnessError(error, "session"); - } - }); + async waitForIdle(): Promise {} + async runWhenIdle(callback: () => void | Promise): Promise { + await callback(); } - - async compact(customInstructions?: string): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "compact() requires idle harness"); - this.phase = "compaction"; - const operation = this.startOperation(); - try { - const model = this.model; - if (!model) throw new AgentHarnessError("invalid_state", "No model set for compaction"); - const branchEntries = await this.session.getBranch(); - const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); - if (!preparationResult.ok) throw preparationResult.error; - const preparation = preparationResult.value; - if (!preparation) throw new AgentHarnessError("compaction", "Nothing to compact"); - const hookResult = await this.emitHook({ - type: "session_before_compact", - preparation, - branchEntries, - customInstructions, - signal: operation.signal, - }); - if (hookResult?.cancel) throw new AgentHarnessError("compaction", "Compaction cancelled"); - const provided = hookResult?.compaction; - const compactResult = provided - ? { ok: true as const, value: provided } - : await compact( - preparation, - this.models, - model, - customInstructions, - operation.signal, - this.thinkingLevel, - this.retry, - this.retryCallbacks("compaction"), - ); - if (!compactResult.ok) throw compactResult.error; - const result = compactResult.value; - this.assertNotShutDown(); - const entryId = await this.session.appendCompaction( - result.summary, - result.firstKeptEntryId, - result.tokensBefore, - result.details, - provided !== undefined, - result.usage, - result.retainedTail, - ); - const entry = await this.session.getEntry(entryId); - if (entry?.type === "compaction") { - await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined }); - } - return result; - } catch (error) { - throw normalizeHarnessError(error, "compaction"); - } finally { - this.phase = "idle"; - operation.finish(); - } + async peekAction(): Promise { + return undefined; } - - async navigateTree( - targetId: string, - options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, - ): Promise { - this.assertNotShutDown(); - if (this.phase !== "idle") throw new AgentHarnessError("busy", "navigateTree() requires idle harness"); - this.phase = "branch_summary"; - const operation = this.startOperation(); - try { - const oldLeafId = await this.session.getLeafId(); - if (oldLeafId === targetId) return { cancelled: false }; - const targetEntry = await this.session.getEntry(targetId); - if (!targetEntry) throw new AgentHarnessError("invalid_argument", `Entry ${targetId} not found`); - const { entries, commonAncestorId } = await collectEntriesForBranchSummary(this.session, oldLeafId, targetId); - const preparation = { - targetId, - oldLeafId, - commonAncestorId, - entriesToSummarize: entries, - userWantsSummary: options?.summarize ?? false, - customInstructions: options?.customInstructions, - replaceInstructions: options?.replaceInstructions, - label: options?.label, - }; - const hookResult = await this.emitHook({ - type: "session_before_tree", - preparation, - signal: operation.signal, - }); - if (hookResult?.cancel) return { cancelled: true }; - let summaryEntry: NavigateTreeResult["summaryEntry"]; - let summaryText: string | undefined = hookResult?.summary?.summary; - let summaryDetails: unknown = hookResult?.summary?.details; - let summaryUsage = hookResult?.summary?.usage; - if (!summaryText && options?.summarize && entries.length > 0) { - const model = this.model; - if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary"); - const branchSummary = await generateBranchSummary(entries, { - models: this.models, - model, - signal: operation.signal, - customInstructions: hookResult?.customInstructions ?? options?.customInstructions, - replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, - retry: this.retry, - callbacks: this.retryCallbacks("branch_summary"), - }); - if (!branchSummary.ok) { - if (branchSummary.error.code === "aborted") return { cancelled: true }; - throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error); - } - summaryText = branchSummary.value.summary; - summaryUsage = branchSummary.value.usage; - summaryDetails = { - readFiles: branchSummary.value.readFiles, - modifiedFiles: branchSummary.value.modifiedFiles, - }; - } - let editorText: string | undefined; - let newLeafId: string | null; - if (targetEntry.type === "message" && targetEntry.message.role === "user") { - newLeafId = targetEntry.parentId; - editorText = contentText(targetEntry.message.content, ""); - } else if (targetEntry.type === "custom_message") { - newLeafId = targetEntry.parentId; - editorText = contentText(targetEntry.content, ""); - } else { - newLeafId = targetId; - } - this.assertNotShutDown(); - const summaryId = await this.session.moveTo( - newLeafId, - summaryText - ? { - summary: summaryText, - details: summaryDetails, - usage: summaryUsage, - fromHook: hookResult?.summary !== undefined, - } - : undefined, - ); - if (summaryId) { - const entry = await this.session.getEntry(summaryId); - if (entry?.type === "branch_summary") summaryEntry = entry; - } - await this.emitOwn({ - type: "session_tree", - newLeafId: await this.session.getLeafId(), - oldLeafId, - summaryEntry, - fromHook: hookResult?.summary !== undefined, - }); - return { cancelled: false, editorText, summaryEntry }; - } catch (error) { - throw normalizeHarnessError(error, "branch_summary"); - } finally { - this.phase = "idle"; - operation.finish(); - } + async executeAction(): Promise { + return undefined; } - - getModel(): Model { + async runToCompletion(): Promise {} + async getModel(): Promise> { return this.model; } - - async setModel(model: Model): Promise { - this.assertNotShutDown(); - return this.track("mutation", async () => { - try { - const previousModel = this.model; - if (this.phase === "idle") { - await this.session.appendModelChange(model.provider, model.id); - } else { - this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); - } - this.model = model; - await this.emitOwn({ type: "model_update", model, previousModel, source: "set" }); - } catch (error) { - throw normalizeHarnessError(error, "session"); - } - }); + async setModel(model: Model): Promise { + this.model = model; } - - getThinkingLevel(): ThinkingLevel { + async getThinkingLevel(): Promise { return this.thinkingLevel; } - async setThinkingLevel(level: ThinkingLevel): Promise { - this.assertNotShutDown(); - return this.track("mutation", async () => { - try { - const previousLevel = this.thinkingLevel; - if (this.phase === "idle") { - await this.session.appendThinkingLevelChange(level); - } else { - this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); - } - this.thinkingLevel = level; - await this.emitOwn({ type: "thinking_level_update", level, previousLevel }); - } catch (error) { - throw normalizeHarnessError(error, "session"); - } - }); - } - - getTools(): TTool[] { - return [...this.tools.values()]; - } - - async setTools(tools: TTool[], activeToolNames?: string[]): Promise { - this.assertNotShutDown(); - return this.track("mutation", () => this.applyTools(tools, activeToolNames)); + this.thinkingLevel = level; } - - private async applyTools(tools: TTool[], activeToolNames?: string[]): Promise { - try { - this.validateUniqueNames( - tools.map((tool) => tool.name), - "Duplicate tool name(s)", - ); - const nextTools = new Map(tools.map((tool) => [tool.name, tool])); - const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; - this.validateToolNames(nextActiveToolNames, nextTools); - const previousToolNames = [...this.tools.keys()]; - const previousActiveToolNames = [...this.activeToolNames]; - if (this.phase === "idle") { - await this.session.appendActiveToolsChange(nextActiveToolNames); - } else { - this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] }); - } - this.tools = nextTools; - this.activeToolNames = [...nextActiveToolNames]; - await this.emitOwn({ - type: "tools_update", - toolNames: [...this.tools.keys()], - previousToolNames, - activeToolNames: [...this.activeToolNames], - previousActiveToolNames, - source: "set", - }); - } catch (error) { - throw normalizeHarnessError(error, "invalid_argument"); - } + async getActiveTools(): Promise { + return [...this.activeToolNames]; } - - getActiveTools(): TTool[] { - return this.activeToolNames.map((name) => this.tools.get(name)!); + async setActiveTools(names: string[]): Promise { + this.activeToolNames = [...names]; } - - async setActiveTools(toolNames: string[]): Promise { - this.assertNotShutDown(); - return this.track("mutation", () => this.applyActiveTools(toolNames)); + async watch(): Promise> { + const leafId = await this.getLeafId(); + const transcript = + leafId === null ? [] : await this.session.findEntriesOnBranch({ start: leafId, order: "oldestFirst" }); + return { + snapshot: { + lane: this.name, + transcript, + leafId, + operation: null, + queues: { steer: [], followUp: [], nextRun: [] }, + pendingWrites: [], + faulted: false, + }, + start() {}, + unsubscribe() {}, + }; } - private async applyActiveTools(toolNames: string[]): Promise { - try { - this.validateToolNames(toolNames); - const previousToolNames = [...this.tools.keys()]; - const previousActiveToolNames = [...this.activeToolNames]; - if (this.phase === "idle") { - await this.session.appendActiveToolsChange(toolNames); - } else { - this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] }); - } - this.activeToolNames = [...toolNames]; - await this.emitOwn({ - type: "tools_update", - toolNames: [...this.tools.keys()], - previousToolNames, - activeToolNames: [...this.activeToolNames], - previousActiveToolNames, - source: "set", - }); - } catch (error) { - throw normalizeHarnessError(error, "invalid_argument"); - } + async lane(name: string): Promise { + return name === "main" ? this : undefined; } - - getSteeringMode(): QueueMode { - return this.steeringQueueMode; + async createLane(_name: string, _at: string | null): Promise { + return this.unavailable("createLane"); } - - async setSteeringMode(mode: QueueMode): Promise { - this.assertNotShutDown(); - this.steeringQueueMode = mode; + async lanes(): Promise { + return (await this.durableSession.getLanes()).map(({ lane, leafId }) => ({ + name: lane, + leafId, + operation: null, + })); } - - getFollowUpMode(): QueueMode { - return this.followUpQueueMode; + async getTools(): Promise { + return [...this.tools]; } - - async setFollowUpMode(mode: QueueMode): Promise { - this.assertNotShutDown(); - this.followUpQueueMode = mode; + async setTools(tools: HarnessTool[], activeNames?: string[]): Promise { + this.tools = [...tools]; + this.activeToolNames = [...(activeNames ?? tools.map((tool) => tool.name))]; } - - getResources(): AgentHarnessResources { + async getResources(): Promise { return { - skills: this.resources.skills?.slice(), - promptTemplates: this.resources.promptTemplates?.slice(), + skills: this.resources.skills ? [...this.resources.skills] : undefined, + promptTemplates: this.resources.promptTemplates ? [...this.resources.promptTemplates] : undefined, }; } - - async setResources(resources: AgentHarnessResources): Promise { - this.assertNotShutDown(); - const previousResources = this.getResources(); + async setResources(resources: Resources): Promise { this.resources = { - skills: resources.skills?.slice(), - promptTemplates: resources.promptTemplates?.slice(), + skills: resources.skills ? [...resources.skills] : undefined, + promptTemplates: resources.promptTemplates ? [...resources.promptTemplates] : undefined, }; - await this.emitOwn({ type: "resources_update", resources: this.getResources(), previousResources }); } - - getStreamOptions(): AgentHarnessStreamOptions { - return cloneStreamOptions(this.streamOptions); + async getStreamOptions(): Promise { + return { ...this.streamOptions }; } - - async setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise { - this.assertNotShutDown(); - this.streamOptions = cloneStreamOptions(streamOptions); + async setStreamOptions(options: StreamOptions): Promise { + this.streamOptions = { ...options }; } - - /** Permanently stop this harness instance without deleting its durable session. */ - requestShutdown(): void { - if (this.isShutdown) return; - this.isShutdown = true; - this.pendingSessionWrites = []; - this.steerQueue = []; - this.followUpQueue = []; - this.nextTurnQueue = []; - this.activeAbortController?.abort(); - this.shutdownPromise = this.waitForTasks(); + async getRetryPolicy(): Promise { + return { ...this.retryPolicy }; } - - /** Waits for work active when shutdown was requested to settle. */ - waitForShutdown(): Promise { - if (!this.shutdownPromise) { - return Promise.reject(new AgentHarnessError("invalid_state", "Shutdown has not been requested")); - } - return this.shutdownPromise; + async setRetryPolicy(policy: RetryPolicy): Promise { + this.retryPolicy = { ...policy }; } - - async abort(): Promise { - this.assertNotShutDown(); - const clearedSteer = [...this.steerQueue]; - const clearedFollowUp = [...this.followUpQueue]; - this.steerQueue = []; - this.followUpQueue = []; - this.activeAbortController?.abort(); - const errors: Error[] = []; - try { - await this.emitQueueUpdate(); - } catch (error) { - errors.push(toError(error)); - } - try { - await this.waitForIdle(); - } catch (error) { - errors.push(toError(error)); - } - try { - await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); - } catch (error) { - errors.push(toError(error)); - } - if (errors.length > 0) { - const cause = errors.length === 1 ? errors[0]! : new AggregateError(errors, "Abort completed with errors"); - throw normalizeHarnessError(cause, "hook"); - } - return { clearedSteer, clearedFollowUp }; + async getCompactionSettings(): Promise { + return { ...this.compactionSettings }; } - - async waitForIdle(): Promise { - await this.waitForTasks("operation"); + async setCompactionSettings(settings: CompactionSettings): Promise { + this.compactionSettings = { ...settings }; } - - subscribe( - listener: (event: AgentHarnessEvent, signal?: AbortSignal) => Promise | void, - ): () => void { - this.assertNotShutDown(); - let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE); - if (!handlers) { - handlers = new Set(); - this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers); - } - handlers.add(listener as AgentHarnessHandler); - return () => handlers!.delete(listener as AgentHarnessHandler); + async getSteeringMode(): Promise { + return this.steeringMode; } - - on( - type: TType, - handler: ( - event: Extract, - ) => Promise | AgentHarnessEventResultMap[TType], - ): () => void { - this.assertNotShutDown(); - let handlers = this.handlers.get(type); - if (!handlers) { - handlers = new Set(); - this.handlers.set(type, handlers); - } - handlers.add(handler as AgentHarnessHandler); - return () => handlers!.delete(handler as AgentHarnessHandler); + async setSteeringMode(mode: QueueMode): Promise { + this.steeringMode = mode; + } + async getFollowUpMode(): Promise { + return this.followUpMode; + } + async setFollowUpMode(mode: QueueMode): Promise { + this.followUpMode = mode; + } + async watchSession(): Promise> { + return { + snapshot: { lanes: await this.lanes(), faulted: false }, + start() {}, + unsubscribe() {}, + }; + } + async close(): Promise { + this.closed = true; } } diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index eb545eb1241..62e933454d2 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -1,14 +1,17 @@ -import { contentText, type Model, type Models, type RetryCallbacks, type RetryPolicy } from "@cheetahbyte/pi-ai"; +import { + type Api, + contentText, + type Model, + type Models, + type RetryCallbacks, + type RetryPolicy, + type Usage, +} from "@cheetahbyte/pi-ai"; import type { AgentMessage } from "../../types.ts"; -import { - convertToLlm, - createBranchSummaryMessage, - createCompactionSummaryMessage, - createCustomMessage, -} from "../messages.ts"; -import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts"; -import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts"; +import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; +import { type Entry, type Session, SessionError } from "../session/index.ts"; +import { BranchSummaryError, err, ok, type Result } from "../types.ts"; import { completeSimpleWithRetries, estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts"; import { computeFileLists, @@ -19,6 +22,14 @@ import { serializeConversation, } from "./utils.ts"; +/** Generated branch summary data ready to be persisted as a branch-summary entry. */ +export interface BranchSummaryResult { + summary: string; + usage?: Usage; + readFiles: string[]; + modifiedFiles: string[]; +} + /** File-operation details stored on generated branch summary entries. */ export interface BranchSummaryDetails { /** Files read while exploring the summarized branch. */ @@ -42,7 +53,7 @@ export interface BranchPreparation { /** Entries selected for branch summarization. */ export interface CollectEntriesResult { /** Entries to summarize in chronological order. */ - entries: SessionTreeEntry[]; + entries: Entry[]; /** Deepest common ancestor between the previous leaf and target entry. */ commonAncestorId: string | null; } @@ -52,7 +63,7 @@ export interface GenerateBranchSummaryOptions { /** Provider collection the summarization request goes through; owns auth resolution. */ models: Models; /** Model used for summarization. */ - model: Model; + model: Model; /** Abort signal for the summarization request. */ signal: AbortSignal; /** Optional instructions appended to or replacing the default prompt. */ @@ -76,37 +87,34 @@ export async function collectEntriesForBranchSummary( if (!oldLeafId) { return { entries: [], commonAncestorId: null }; } - const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id)); - const targetPath = await session.getBranch(targetId); + const oldPath = new Set((await session.findEntriesOnBranch({ start: oldLeafId })).map((entry) => entry.id)); + const targetPath = await session.findEntriesOnBranch({ start: targetId }); let commonAncestorId: string | null = null; - for (let i = targetPath.length - 1; i >= 0; i--) { - if (oldPath.has(targetPath[i].id)) { - commonAncestorId = targetPath[i].id; + for (const entry of targetPath) { + if (oldPath.has(entry.id)) { + commonAncestorId = entry.id; break; } } - const entries: SessionTreeEntry[] = []; + const entries: Entry[] = []; let current: string | null = oldLeafId; while (current && current !== commonAncestorId) { const entry = await session.getEntry(current); - if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`); - entries.push(entry as SessionTreeEntry); + if (!entry) throw new SessionError("invalid_entry", `Entry ${current} not found`); + entries.push(entry); current = entry.parentId; } entries.reverse(); return { entries, commonAncestorId }; } -function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { +function getMessageFromEntry(entry: Entry): AgentMessage | undefined { switch (entry.type) { case "message": if (entry.message.role === "toolResult") return undefined; return entry.message; - case "custom_message": - return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); - case "branch_summary": return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); @@ -116,20 +124,17 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined case "model_change": case "active_tools_change": case "custom": - case "label": - case "session_info": - case "leaf": return undefined; } } /** Prepare branch entries for summarization within an optional token budget. */ -export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation { +export function prepareBranchEntries(entries: Entry[], tokenBudget: number = 0): BranchPreparation { const messages: AgentMessage[] = []; const fileOps = createFileOps(); let totalTokens = 0; for (const entry of entries) { - if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + if (entry.type === "branch_summary" && entry.details) { const details = entry.details as BranchSummaryDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); @@ -201,7 +206,7 @@ Keep each section concise. Preserve exact file paths, function names, and error /** Generate a summary for abandoned branch entries. */ export async function generateBranchSummary( - entries: SessionTreeEntry[], + entries: Entry[], options: GenerateBranchSummaryOptions, ): Promise> { const { diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 6a00e7745c3..164d58cf32d 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,27 +1,22 @@ import { + type Api, type AssistantMessage, type Context, contentText, - type ImageContent, type Model, type Models, type RetryCallbacks, type RetryPolicy, retryAssistantCall, type SimpleStreamOptions, - type TextContent, type Usage, uuidv7, } from "@cheetahbyte/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; -import { - convertToLlm, - createBranchSummaryMessage, - createCompactionSummaryMessage, - createCustomMessage, -} from "../messages.ts"; -import { buildSessionContext } from "../session/session.ts"; -import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types.ts"; +import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; +import { buildSessionContext } from "../session/context.ts"; +import type { CompactionEntry, Entry } from "../session/types.ts"; +import { CompactionError, err, ok, type Result } from "../types.ts"; import { computeFileLists, createFileOps, @@ -48,13 +43,13 @@ function safeJsonStringify(value: unknown): string { function extractFileOperations( messages: AgentMessage[], - entries: SessionTreeEntry[], + entries: Entry[], prevCompactionIndex: number, ): FileOperations { const fileOps = createFileOps(); if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; - if (!prevCompaction.fromHook && prevCompaction.details) { + if (prevCompaction.details) { const details = prevCompaction.details as CompactionDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); @@ -70,19 +65,10 @@ function extractFileOperations( return fileOps; } -function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { +function getMessageFromEntry(entry: Entry): AgentMessage | undefined { if (entry.type === "message") { return entry.message as AgentMessage; } - if (entry.type === "custom_message") { - return createCustomMessage( - entry.customType, - entry.content as string | (TextContent | ImageContent)[], - entry.display, - entry.details, - entry.timestamp, - ); - } if (entry.type === "branch_summary") { return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); } @@ -92,7 +78,7 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined return undefined; } -function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined { +function getMessageFromEntryForCompaction(entry: Entry): AgentMessage | undefined { if (entry.type === "compaction") { return undefined; } @@ -100,24 +86,22 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage } /** Generated compaction data ready to be persisted as a compaction entry. */ -export interface CompactionResult { +export interface CompactResult { /** Summary text that replaces compacted history in future context. */ summary: string; - /** Entry id where retained history starts. Optional during Pi 2.0 transition. */ - firstKeptEntryId?: string; /** Estimated context tokens before compaction. */ tokensBefore: number; /** Usage from the LLM call(s) that generated this summary, if available. */ usage?: Usage; - /** Retained recent messages stored directly on the compaction entry. Optional during Pi 2.0 transition. */ - retainedTail?: AgentMessage[]; + /** Retained recent messages stored directly on the compaction entry. */ + retainedTail: AgentMessage[]; /** Optional implementation-specific details stored with the compaction entry. */ details?: T; } export async function completeSimpleWithRetries( models: Models, - model: Model, + model: Model, context: Context, options: SimpleStreamOptions, retry?: RetryPolicy, @@ -197,7 +181,7 @@ function getAssistantUsage(msg: AgentMessage): Usage | undefined { } /** Return usage from the last valid assistant message in session entries. */ -export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined { +export function getLastAssistantUsage(entries: Entry[]): Usage | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "message") { @@ -325,7 +309,7 @@ export function estimateTokens(message: AgentMessage): number { return 0; } -function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] { +function findValidCutPoints(entries: Entry[], startIndex: number, endIndex: number): number[] { const cutPoints: number[] = []; for (let i = startIndex; i < endIndex; i++) { const entry = entries[i]; @@ -352,24 +336,18 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end case "compaction": case "branch_summary": case "custom": - case "custom_message": - case "label": - case "session_info": - case "leaf": break; } - if (entry.type === "branch_summary" || entry.type === "custom_message") { - cutPoints.push(i); - } + if (entry.type === "branch_summary") cutPoints.push(i); } return cutPoints; } /** Find the user-visible message that starts the turn containing an entry. */ -export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number { +export function findTurnStartIndex(entries: Entry[], entryIndex: number, startIndex: number): number { for (let i = entryIndex; i >= startIndex; i--) { const entry = entries[i]; - if (entry.type === "branch_summary" || entry.type === "custom_message") { + if (entry.type === "branch_summary") { return i; } if (entry.type === "message") { @@ -394,7 +372,7 @@ export interface CutPointResult { /** Find the compaction cut point that keeps approximately the requested recent-token budget. */ export function findCutPoint( - entries: SessionTreeEntry[], + entries: Entry[], startIndex: number, endIndex: number, keepRecentTokens: number, @@ -523,7 +501,7 @@ Keep each section concise. Preserve exact file paths, function names, and error export async function generateSummary( currentMessages: AgentMessage[], models: Models, - model: Model, + model: Model, reserveTokens: number, signal?: AbortSignal, customInstructions?: string, @@ -551,7 +529,7 @@ export async function generateSummary( export async function generateSummaryWithUsage( currentMessages: AgentMessage[], models: Models, - model: Model, + model: Model, reserveTokens: number, signal?: AbortSignal, customInstructions?: string, @@ -616,8 +594,6 @@ export async function generateSummaryWithUsage( /** Prepared inputs for a compaction run. */ export interface CompactionPreparation { - /** Entry id where retained history starts. */ - firstKeptEntryId: string; /** Messages summarized into the history summary. */ messagesToSummarize: AgentMessage[]; /** Prefix messages summarized separately when compaction splits a turn. */ @@ -638,7 +614,7 @@ export interface CompactionPreparation { /** Prepare session entries for compaction, or return undefined when compaction is not applicable. */ export function prepareCompaction( - pathEntries: SessionTreeEntry[], + pathEntries: Entry[], settings: CompactionSettings, ): Result { if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") { @@ -654,42 +630,42 @@ export function prepareCompaction( } let previousSummary: string | undefined; - let boundaryStart = 0; + let compactableEntries = pathEntries; if (prevCompactionIndex >= 0) { const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; previousSummary = prevCompaction.summary; - const firstKeptEntryIndex = prevCompaction.firstKeptEntryId - ? pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId) - : -1; - boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; - } - const boundaryEnd = pathEntries.length; + const retainedTail = prevCompaction.retainedTail ?? []; + const virtualRetainedEntries: Entry[] = retainedTail.map((message, index) => ({ + type: "message", + id: `${prevCompaction.id}:retained:${index}`, + parentId: index === 0 ? prevCompaction.id : `${prevCompaction.id}:retained:${index - 1}`, + seq: prevCompaction.seq, + timestamp: message.timestamp, + message, + })); + compactableEntries = [...virtualRetainedEntries, ...pathEntries.slice(prevCompactionIndex + 1)]; + } + const boundaryEnd = compactableEntries.length; const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; - const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); - const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; - if (!firstKeptEntry?.id) { - return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); - } - const firstKeptEntryId = firstKeptEntry.id; - + const cutPoint = findCutPoint(compactableEntries, 0, boundaryEnd, settings.keepRecentTokens); const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; const messagesToSummarize: AgentMessage[] = []; - for (let i = boundaryStart; i < historyEnd; i++) { - const msg = getMessageFromEntryForCompaction(pathEntries[i]); + for (let i = 0; i < historyEnd; i++) { + const msg = getMessageFromEntryForCompaction(compactableEntries[i]); if (msg) messagesToSummarize.push(msg); } const turnPrefixMessages: AgentMessage[] = []; if (cutPoint.isSplitTurn) { for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { - const msg = getMessageFromEntryForCompaction(pathEntries[i]); + const msg = getMessageFromEntryForCompaction(compactableEntries[i]); if (msg) turnPrefixMessages.push(msg); } } const retainedTail: AgentMessage[] = []; for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) { - const msg = getMessageFromEntryForCompaction(pathEntries[i]); + const msg = getMessageFromEntryForCompaction(compactableEntries[i]); if (msg) retainedTail.push(msg); } const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); @@ -700,7 +676,6 @@ export function prepareCompaction( } return ok({ - firstKeptEntryId, messagesToSummarize, turnPrefixMessages, retainedTail, @@ -733,15 +708,14 @@ export { serializeConversation } from "./utils.ts"; export async function compact( preparation: CompactionPreparation, models: Models, - model: Model, + model: Model, customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, retry?: RetryPolicy, callbacks?: RetryCallbacks, -): Promise> { +): Promise> { const { - firstKeptEntryId, messagesToSummarize, turnPrefixMessages, retainedTail, @@ -752,10 +726,6 @@ export async function compact( settings, } = preparation; - if (!firstKeptEntryId) { - return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); - } - let summary: string; let summaryUsage: Usage; @@ -817,7 +787,6 @@ export async function compact( return ok({ summary, - firstKeptEntryId, tokensBefore, usage: summaryUsage, retainedTail, @@ -827,7 +796,7 @@ export async function compact( async function generateTurnPrefixSummary( messages: AgentMessage[], models: Models, - model: Model, + model: Model, reserveTokens: number, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, diff --git a/packages/agent/src/harness/experimental/session/session.ts b/packages/agent/src/harness/experimental/session/session.ts deleted file mode 100644 index 805bb9055c6..00000000000 --- a/packages/agent/src/harness/experimental/session/session.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { uuidv7 } from "@cheetahbyte/pi-ai"; -import type { AgentMessage } from "../../../types.ts"; -import type { - BranchBounds, - Entry, - EntryQuery, - IdGenerator, - LanePointer, - LaneRecord, - LogItem, - LogOptions, - NewRecord, - ProvisionedEntry, - RecordQuery, - SessionMetadata, - SessionStats, - SessionStorage, - SessionTree, -} from "./types.ts"; -import { SessionError } from "./types.ts"; - -type JsonValidationFrame = { value: unknown } | { exit: object }; - -function invalidPayload(reason: string): never { - throw new SessionError("invalid_payload", `Durable payload ${reason}`); -} - -function assertValidLimit(limit: number | undefined): void { - if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) { - throw new SessionError("invalid_query", "limit must be a positive integer"); - } -} - -function assertValidCursor(afterSeq: number | undefined): void { - if (afterSeq !== undefined && (!Number.isInteger(afterSeq) || afterSeq < 0)) { - throw new SessionError("invalid_query", "cursor sequence must be a non-negative integer"); - } -} - -function assertJsonSerializable(value: unknown): void { - const active = new WeakSet(); - const stack: JsonValidationFrame[] = [{ value }]; - while (stack.length > 0) { - const frame = stack.pop()!; - if ("exit" in frame) { - active.delete(frame.exit); - continue; - } - const candidate = frame.value; - if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") { - continue; - } - if (typeof candidate === "number") { - if (!Number.isFinite(candidate)) invalidPayload("contains a non-finite number"); - continue; - } - if (typeof candidate !== "object") invalidPayload(`contains ${typeof candidate}`); - if (active.has(candidate)) invalidPayload("contains a cycle"); - active.add(candidate); - stack.push({ exit: candidate }); - - if (Array.isArray(candidate)) { - if (Object.getPrototypeOf(candidate) !== Array.prototype) { - invalidPayload("contains a non-standard array"); - } - if ( - Object.getOwnPropertySymbols(candidate).length > 0 || - Object.getOwnPropertyNames(candidate).length !== candidate.length + 1 - ) { - invalidPayload("contains an array with unsupported properties"); - } - for (let index = candidate.length - 1; index >= 0; index--) { - if (!Object.hasOwn(candidate, index)) invalidPayload("contains a sparse array"); - const descriptor = Object.getOwnPropertyDescriptor(candidate, index)!; - if (!("value" in descriptor)) invalidPayload("contains an array accessor"); - stack.push({ value: descriptor.value }); - } - continue; - } - - const prototype = Object.getPrototypeOf(candidate); - if (prototype !== Object.prototype && prototype !== null) { - invalidPayload("contains a non-plain object"); - } - if (Object.getOwnPropertySymbols(candidate).length > 0) { - invalidPayload("contains a symbol-keyed property"); - } - const keys = Object.keys(candidate); - if (Object.getOwnPropertyNames(candidate).length !== keys.length) { - invalidPayload("contains a non-enumerable property"); - } - for (let index = keys.length - 1; index >= 0; index--) { - const descriptor = Object.getOwnPropertyDescriptor(candidate, keys[index]!)!; - if (!("value" in descriptor)) invalidPayload("contains an accessor"); - stack.push({ value: descriptor.value }); - } - } -} - -export class Session implements SessionTree { - private readonly storage: SessionStorage; - readonly idGenerator: IdGenerator; - - constructor(storage: SessionStorage, options: { idGenerator?: IdGenerator } = {}) { - this.storage = storage; - this.idGenerator = options.idGenerator ?? { next: () => uuidv7() }; - } - - async getMetadata(): Promise { - return this.storage.getMetadata(); - } - - view(lane: string): SessionTree { - if (lane === "main") return this; - return { - getLeafId: () => this.getLeafIdForLane(lane), - getEntry: (id) => this.getEntry(id), - getStats: () => this.getStats(), - getName: () => this.getName(), - setName: (name) => this.setName(name), - getLabel: (targetId) => this.getLabel(targetId), - setLabel: (targetId, label) => this.setLabel(targetId, label), - findEntries: (query) => this.queryEntries(query), - findEntry: async (query = {}) => (await this.queryEntries(query, 1))[0], - findEntriesOnBranch: (query) => this.queryBranchEntries(lane, query), - findEntryOnBranch: async (query = {}) => (await this.queryBranchEntries(lane, query, 1))[0], - appendMessage: (message) => this.appendMessageToLane(lane, message), - appendCustomEntry: (customType, data) => this.appendCustomEntryToLane(lane, customType, data), - }; - } - - async getLeafId(): Promise { - return this.getLeafIdForLane("main"); - } - - async getEntry(id: string): Promise { - return this.storage.getEntry(id); - } - - async getStats(): Promise { - return this.storage.getStats(); - } - - async getName(): Promise { - return this.storage.getName(); - } - - async setName(name: string): Promise { - await this.storage.setName(name); - } - - async getLabel(targetId: string): Promise { - return this.storage.getLabel(targetId); - } - - async setLabel(targetId: string, label: string | undefined): Promise { - await this.storage.setLabel(targetId, label); - } - - async findEntries(query?: EntryQuery): Promise { - return this.queryEntries(query); - } - - async findEntry(query: EntryQuery = {}): Promise { - return (await this.queryEntries(query, 1))[0]; - } - - async findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise { - return this.queryBranchEntries("main", query); - } - - async findEntryOnBranch(query: EntryQuery & BranchBounds = {}): Promise { - return (await this.queryBranchEntries("main", query, 1))[0]; - } - - async appendMessage(message: AgentMessage): Promise { - return this.appendMessageToLane("main", message); - } - - async appendCustomEntry(customType: string, data?: unknown): Promise { - return this.appendCustomEntryToLane("main", customType, data); - } - - async getLanes(): Promise { - return this.storage.getLanes(); - } - - async createLane(lane: string, at: string | null): Promise { - await this.storage.createLane(lane, at); - } - - async moveLane(lane: string, to: string | null): Promise { - await this.storage.moveLane(lane, to); - } - - async appendEntry(entry: ProvisionedEntry, lane: string): Promise { - return this.commitEntry(entry, lane); - } - - async appendRecord( - record: TNewRecord, - ): Promise>; - async appendRecord(record: NewRecord): Promise; - async appendRecord(record: NewRecord): Promise { - return this.commitRecord(record); - } - - async findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - async findRecords(query?: RecordQuery): Promise; - async findRecords(query?: RecordQuery): Promise { - return this.queryRecords(query); - } - - async getLog(options?: LogOptions): Promise { - return this.queryLog(options); - } - - private async getLeafIdForLane(lane: string): Promise { - const pointer = (await this.storage.getLanes()).find((candidate) => candidate.lane === lane); - if (!pointer) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - return pointer.leafId; - } - - private async queryEntries(query: EntryQuery = {}, resultLimit = query.limit): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - return this.storage.findEntries(resultLimit === query.limit ? query : { ...query, limit: resultLimit }); - } - - private async queryBranchEntries( - lane: string, - query: EntryQuery & BranchBounds = {}, - resultLimit = query.limit, - ): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - const start = query.start ?? (await this.getLeafIdForLane(lane)); - if (start === null) return []; - const storageQuery = resultLimit === query.limit ? query : { ...query, limit: resultLimit }; - return this.storage.findEntriesOnBranch({ ...storageQuery, start }); - } - - private async queryRecords(query: RecordQuery = {}): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.afterSeq); - return this.storage.findRecords(query); - } - - private async queryLog(options: LogOptions = {}): Promise { - assertValidLimit(options.limit); - assertValidCursor(options.afterSeq); - return this.storage.getLog(options); - } - - private async appendMessageToLane(lane: string, message: AgentMessage): Promise { - const entry = await this.commitEntry({ type: "message", id: this.idGenerator.next(), message }, lane); - return entry.id; - } - - private async appendCustomEntryToLane(lane: string, customType: string, data?: unknown): Promise { - const entry = await this.commitEntry( - data === undefined - ? { type: "custom", id: this.idGenerator.next(), customType } - : { type: "custom", id: this.idGenerator.next(), customType, data }, - lane, - ); - return entry.id; - } - - private async commitEntry(entry: ProvisionedEntry, lane: string): Promise { - assertJsonSerializable(entry); - return this.storage.appendEntry(entry, lane); - } - - private async commitRecord(record: NewRecord): Promise { - assertJsonSerializable(record); - return this.storage.appendRecord(record); - } -} diff --git a/packages/agent/src/harness/messages.ts b/packages/agent/src/harness/messages.ts index 99e843b69cc..5ffdb8f3c5c 100644 --- a/packages/agent/src/harness/messages.ts +++ b/packages/agent/src/harness/messages.ts @@ -78,25 +78,29 @@ export function bashExecutionToText(msg: BashExecutionMessage): string { return text; } -export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { +export function createBranchSummaryMessage( + summary: string, + fromId: string, + timestamp: string | number, +): BranchSummaryMessage { return { role: "branchSummary", summary, fromId, - timestamp: new Date(timestamp).getTime(), + timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime(), }; } export function createCompactionSummaryMessage( summary: string, tokensBefore: number, - timestamp: string, + timestamp: string | number, ): CompactionSummaryMessage { return { role: "compactionSummary", summary, tokensBefore, - timestamp: new Date(timestamp).getTime(), + timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime(), }; } @@ -105,7 +109,7 @@ export function createCustomMessage( content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, - timestamp: string, + timestamp: string | number, ): CustomMessage { return { role: "custom", @@ -113,7 +117,7 @@ export function createCustomMessage( content, display, details, - timestamp: new Date(timestamp).getTime(), + timestamp: typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime(), }; } diff --git a/packages/agent/src/harness/result.ts b/packages/agent/src/harness/result.ts new file mode 100644 index 00000000000..d4bfda4f4b2 --- /dev/null +++ b/packages/agent/src/harness/result.ts @@ -0,0 +1,63 @@ +export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; + +export const Result = { + ok(value: TValue): Result { + return { ok: true, value }; + }, + err(error: TError): Result { + return { ok: false, error }; + }, + isOk(result: Result): result is { ok: true; value: TValue } { + return result.ok; + }, + isErr(result: Result): result is { ok: false; error: TError } { + return !result.ok; + }, +}; + +export interface TaggedErrorValue extends Error { + readonly _tag: Tag; + toJSON(): { _tag: Tag; message: string } & Record; +} + +export interface TaggedErrorFactory { + new (props: Props): TaggedErrorValue & Readonly; + is(value: unknown): value is TaggedErrorValue; +} + +export function TaggedError(tag: Tag): TaggedErrorFactory { + class TaggedErrorClass extends Error { + readonly _tag = tag; + + constructor(props: { message: string } & Record) { + super(props.message); + this.name = tag; + Object.assign(this, props); + } + + toJSON(): { _tag: Tag; message: string } & Record { + const payload: Record = {}; + for (const key of Object.keys(this)) { + if (key !== "_tag") payload[key] = (this as unknown as Record)[key]; + } + return { _tag: tag, message: this.message, ...payload }; + } + + static is(value: unknown): value is TaggedErrorValue { + return value instanceof TaggedErrorClass; + } + } + return TaggedErrorClass as unknown as TaggedErrorFactory; +} + +export type ErrorMatchers, TValue> = { + [Tag in TError["_tag"]]: (error: Extract) => TValue; +}; + +export function matchError, TValue>( + error: TError, + matchers: ErrorMatchers, +): TValue { + const matcher = (matchers as unknown as Record TValue>)[error._tag]; + return matcher(error); +} diff --git a/packages/agent/src/harness/session/array-session-index.ts b/packages/agent/src/harness/session/array-session-index.ts deleted file mode 100644 index 248982abff9..00000000000 --- a/packages/agent/src/harness/session/array-session-index.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { - SessionBranchQuery, - SessionEntryCursorOptions, - SessionHead, - SessionStats, - SessionTreeEntry, -} from "../types.ts"; -import { SessionError } from "../types.ts"; - -interface SessionEntryProjection { - name: string | undefined; - labelsById: Map; - stats: SessionStats; -} - -function createProjection(): SessionEntryProjection { - return { - name: undefined, - labelsById: new Map(), - stats: { messageCount: 0, cachedTokens: 0, uncachedTokens: 0, totalTokens: 0, costTotal: 0 }, - }; -} - -function applyProjection(projection: SessionEntryProjection, entry: SessionTreeEntry): void { - if (entry.type === "session_info") { - projection.name = entry.name?.trim() || undefined; - } else if (entry.type === "label") { - const label = entry.label?.trim(); - if (label) projection.labelsById.set(entry.targetId, label); - else projection.labelsById.delete(entry.targetId); - } - if (entry.type === "message") projection.stats.messageCount += 1; - const usage = - entry.type === "message" - ? entry.message.role === "assistant" - ? entry.message.usage - : undefined - : entry.type === "compaction" || entry.type === "branch_summary" - ? entry.usage - : undefined; - if ( - !usage || - typeof usage.input !== "number" || - typeof usage.output !== "number" || - typeof usage.cacheRead !== "number" || - typeof usage.cacheWrite !== "number" || - typeof usage.cost?.total !== "number" - ) { - return; - } - projection.stats.cachedTokens += usage.cacheRead; - projection.stats.uncachedTokens += usage.input + usage.cacheWrite; - projection.stats.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; - projection.stats.costTotal += usage.cost.total; -} - -/** Ordered entries and derived projections for array-backed session storage. */ -export class ArraySessionIndex { - private entries: SessionTreeEntry[] = []; - private byId = new Map(); - private leafId: string | null = null; - private projection = createProjection(); - - constructor(entries: readonly SessionTreeEntry[] = []) { - this.replace(entries); - } - - has(id: string): boolean { - return this.byId.has(id); - } - - append(entry: SessionTreeEntry): void { - if (this.byId.has(entry.id)) { - throw new SessionError("invalid_entry", `Entry ${entry.id} already exists`); - } - this.entries.push(entry); - this.byId.set(entry.id, entry); - this.leafId = entry.type === "leaf" ? entry.targetId : entry.id; - applyProjection(this.projection, entry); - } - - replace(entries: readonly SessionTreeEntry[]): void { - const nextEntries = [...entries]; - const nextById = new Map(); - const nextProjection = createProjection(); - let nextLeafId: string | null = null; - for (const entry of nextEntries) { - if (nextById.has(entry.id)) { - throw new SessionError("invalid_entry", `Entry ${entry.id} already exists`); - } - nextById.set(entry.id, entry); - nextLeafId = entry.type === "leaf" ? entry.targetId : entry.id; - applyProjection(nextProjection, entry); - } - this.entries = nextEntries; - this.byId = nextById; - this.leafId = nextLeafId; - this.projection = nextProjection; - } - - readHead(): SessionHead { - if (this.leafId !== null && !this.byId.has(this.leafId)) { - throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); - } - return { leafId: this.leafId }; - } - - readEntry(id: string): SessionTreeEntry | undefined { - return this.byId.get(id); - } - - readEntries(options?: SessionEntryCursorOptions): readonly SessionTreeEntry[] { - const start = options?.afterEntrySeq ?? 0; - const end = options?.limit === undefined ? undefined : start + options.limit; - return this.entries.slice(start, end); - } - - findEntriesOnBranch(query: SessionBranchQuery & { start: string | null }): readonly SessionTreeEntry[] { - if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit <= 0)) { - throw new RangeError("Session branch query limit must be a positive integer"); - } - if (query.start === null) return []; - const pathFromStart: SessionTreeEntry[] = []; - const visited = new Set(); - let current = this.byId.get(query.start); - if (!current) throw new SessionError("not_found", `Entry ${query.start} not found`); - while (current) { - if (visited.has(current.id)) { - throw new SessionError("invalid_session", `Session branch contains a cycle at ${current.id}`); - } - visited.add(current.id); - pathFromStart.push(current); - if (query.order !== "oldestFirst" && (current.id === query.stopAtId || current.type === query.stopAtType)) { - break; - } - if (!current.parentId) break; - const parent = this.byId.get(current.parentId); - if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); - current = parent; - } - const traversal = query.order === "oldestFirst" ? pathFromStart.reverse() : pathFromStart; - const stopIndex = - query.order === "oldestFirst" - ? traversal.findIndex((entry) => entry.id === query.stopAtId || entry.type === query.stopAtType) - : -1; - const bounded = stopIndex === -1 ? traversal : traversal.slice(0, stopIndex + 1); - const entries = bounded.filter( - (entry) => - (query.type === undefined || entry.type === query.type) && - (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)), - ); - return query.limit === undefined ? entries : entries.slice(0, query.limit); - } - - getLabel(id: string): string | undefined { - return this.projection.labelsById.get(id); - } - - getName(): string | undefined { - return this.projection.name; - } - - getStats(): SessionStats { - return { ...this.projection.stats }; - } - - readPathToRootOrCompaction(requestedLeafId: string | null): readonly SessionTreeEntry[] { - if (requestedLeafId === null) return []; - const path: SessionTreeEntry[] = []; - let stopAtEntryId: string | null = null; - let current = this.byId.get(requestedLeafId); - if (!current) throw new SessionError("not_found", `Entry ${requestedLeafId} not found`); - while (current) { - path.push(current); - if (stopAtEntryId !== null && current.id === stopAtEntryId) break; - if (current.type === "compaction") { - if (current.retainedTail) break; - stopAtEntryId = current.firstKeptEntryId ?? null; - } - if (!current.parentId) break; - const parent = this.byId.get(current.parentId); - if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); - current = parent; - } - return path.reverse(); - } -} diff --git a/packages/agent/src/harness/session/context.ts b/packages/agent/src/harness/session/context.ts new file mode 100644 index 00000000000..add4067f440 --- /dev/null +++ b/packages/agent/src/harness/session/context.ts @@ -0,0 +1,100 @@ +import type { AgentMessage } from "../../types.ts"; +import { createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; +import type { CompactionEntry, CustomEntry, Entry } from "./types.ts"; + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; + activeToolNames: string[] | null; +} + +export type ContextEntryTransform = (entries: readonly Entry[]) => readonly Entry[]; + +export type CustomEntryContextMessageProjector = ( + entry: CustomEntry, + index: number, + entries: readonly Entry[], +) => readonly AgentMessage[] | undefined; + +export interface SessionContextBuildOptions { + entryTransforms?: readonly ContextEntryTransform[]; + entryProjectors?: Readonly>; +} + +function deriveSessionContextState(pathEntries: readonly Entry[]): Omit { + let thinkingLevel = "off"; + let model: { provider: string; modelId: string } | null = null; + let activeToolNames: string[] | null = null; + + for (const entry of pathEntries) { + if (entry.type === "thinking_level_change") { + thinkingLevel = entry.thinkingLevel; + } else if (entry.type === "model_change") { + model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "active_tools_change") { + activeToolNames = [...entry.activeToolNames]; + } + } + + return { thinkingLevel, model, activeToolNames }; +} + +export function defaultContextEntryTransform(pathEntries: readonly Entry[]): Entry[] { + let compaction: CompactionEntry | undefined; + let compactionIndex = -1; + for (let index = pathEntries.length - 1; index >= 0; index--) { + const entry = pathEntries[index]!; + if (entry.type === "compaction") { + compaction = entry; + compactionIndex = index; + break; + } + } + return compaction === undefined ? [...pathEntries] : [compaction, ...pathEntries.slice(compactionIndex + 1)]; +} + +export function buildContextEntries(pathEntries: readonly Entry[], options: SessionContextBuildOptions = {}): Entry[] { + let entries = defaultContextEntryTransform(pathEntries); + for (const transform of options.entryTransforms ?? []) entries = [...transform(entries)]; + return entries; +} + +export function sessionEntryToContextMessages( + entry: Entry, + index: number, + entries: readonly Entry[], + options: SessionContextBuildOptions = {}, +): AgentMessage[] { + if (entry.type === "message") { + if (entry.message.role === "assistant" && entry.message.stopReason === "deferred") return []; + return [entry.message]; + } + if (entry.type === "compaction") { + return [ + createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), + ...(entry.retainedTail ?? []), + ]; + } + if (entry.type === "branch_summary" && entry.summary) { + return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; + } + if (entry.type === "custom") { + return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])]; + } + return []; +} + +export function buildSessionContext( + pathEntries: readonly Entry[], + options: SessionContextBuildOptions = {}, +): SessionContext { + const state = deriveSessionContextState(pathEntries); + const contextEntries = buildContextEntries(pathEntries, options); + const messages = contextEntries.flatMap((entry, index) => + sessionEntryToContextMessages(entry, index, contextEntries, options), + ); + return { ...state, messages }; +} diff --git a/packages/agent/src/harness/experimental/session/index.ts b/packages/agent/src/harness/session/index.ts similarity index 74% rename from packages/agent/src/harness/experimental/session/index.ts rename to packages/agent/src/harness/session/index.ts index 846a90907f4..bc8b4f2bd29 100644 --- a/packages/agent/src/harness/experimental/session/index.ts +++ b/packages/agent/src/harness/session/index.ts @@ -1,3 +1,4 @@ +export * from "./context.ts"; export * from "./memory.ts"; export * from "./session.ts"; export * from "./types.ts"; diff --git a/packages/agent/src/harness/session/jsonl-repo.ts b/packages/agent/src/harness/session/jsonl-repo.ts deleted file mode 100644 index fa074f24fb6..00000000000 --- a/packages/agent/src/harness/session/jsonl-repo.ts +++ /dev/null @@ -1,487 +0,0 @@ -import type { - FileSystem, - JsonlSessionCreateOptions, - JsonlSessionListOptions, - JsonlSessionMetadata, - SessionForkOptions, - SessionForkSelection, - SessionStorage, - SessionTreeEntry, -} from "../types.ts"; -import { SessionError, toError } from "../types.ts"; -import { ArraySessionIndex } from "./array-session-index.ts"; -import { KeyedOperationQueue } from "./keyed-operation-queue.ts"; -import { - createSessionForkSelection, - createSessionId, - createTimestamp, - getFileSystemResultOrThrow, - readSessionEntriesForFork, - type SessionRepository, -} from "./repository.ts"; -import { createSession, type Session, type SessionContextBuildOptions } from "./session.ts"; - -export interface JsonlSessionBackendOptions { - fs: JsonlSessionRepositoryFileSystem; - sessionsRoot: string; - /** Maximum active operations across session keys. Defaults to 4. */ - maxConcurrentOperations?: number; -} -export type JsonlSessionRepositoryFileSystem = Pick< - FileSystem, - | "absolutePath" - | "joinPath" - | "readTextFile" - | "readTextLines" - | "writeFile" - | "appendFile" - | "listDir" - | "exists" - | "createDir" - | "remove" ->; - -type JsonlSessionFileSystem = Pick; - -const DEFAULT_MAX_CONCURRENT_OPERATIONS = 4; - -interface SessionHeader { - type: "session"; - version: 3; - id: string; - timestamp: string; - cwd: string; - parentSession?: string; - metadata?: Record; -} - -interface SessionDocumentDescriptor { - id: string; - timestamp: string; - fileName: string; - operationKey: string; -} - -interface JsonlSessionDocument { - metadata: JsonlSessionMetadata; - entries: SessionTreeEntry[]; -} - -function invalidSession(path: string, message: string, cause?: Error): SessionError { - return new SessionError("invalid_session", `Invalid JSONL session file ${path}: ${message}`, cause); -} - -function invalidEntry(path: string, line: number, message: string, cause?: Error): SessionError { - return new SessionError("invalid_entry", `Invalid JSONL session file ${path}: line ${line} ${message}`, cause); -} - -function parseHeader(line: string, path: string): SessionHeader { - let value: unknown; - try { - value = JSON.parse(line); - } catch (error) { - throw invalidSession(path, "first line is not a valid session header", toError(error)); - } - if (typeof value !== "object" || value === null) - throw invalidSession(path, "first line is not a valid session header"); - const header = value as Partial; - if (header.type !== "session" || header.version !== 3) { - throw invalidSession( - path, - header.type === "session" ? "unsupported session version" : "first line is not a valid session header", - ); - } - if (typeof header.id !== "string" || !header.id) throw invalidSession(path, "session header is missing id"); - if (typeof header.timestamp !== "string" || !header.timestamp) - throw invalidSession(path, "session header is missing timestamp"); - if (typeof header.cwd !== "string" || !header.cwd) throw invalidSession(path, "session header is missing cwd"); - if (header.parentSession !== undefined && typeof header.parentSession !== "string") { - throw invalidSession(path, "session header parentSession must be a string"); - } - if ( - header.metadata !== undefined && - (typeof header.metadata !== "object" || header.metadata === null || Array.isArray(header.metadata)) - ) { - throw invalidSession(path, "session header metadata must be an object"); - } - return { - type: "session", - version: 3, - id: header.id, - timestamp: header.timestamp, - cwd: header.cwd, - parentSession: header.parentSession, - metadata: header.metadata, - }; -} - -function parseEntry(line: string, path: string, lineNumber: number): SessionTreeEntry { - let value: unknown; - try { - value = JSON.parse(line); - } catch (error) { - throw invalidEntry(path, lineNumber, "is not valid JSON", toError(error)); - } - if (typeof value !== "object" || value === null) - throw invalidEntry(path, lineNumber, "is not a valid session entry"); - const entry = value as { - type?: unknown; - id?: unknown; - parentId?: unknown; - timestamp?: unknown; - targetId?: unknown; - }; - if (typeof entry.type !== "string") throw invalidEntry(path, lineNumber, "is missing entry type"); - if (typeof entry.id !== "string" || !entry.id) throw invalidEntry(path, lineNumber, "is missing entry id"); - if (entry.parentId !== null && typeof entry.parentId !== "string") - throw invalidEntry(path, lineNumber, "has invalid parentId"); - if (typeof entry.timestamp !== "string" || !entry.timestamp) - throw invalidEntry(path, lineNumber, "is missing timestamp"); - if (entry.type === "leaf" && entry.targetId !== null && typeof entry.targetId !== "string") { - throw invalidEntry(path, lineNumber, "has invalid targetId"); - } - return entry as SessionTreeEntry; -} - -function metadataFromHeader(header: SessionHeader, path: string): JsonlSessionMetadata { - return { - id: header.id, - createdAt: header.timestamp, - cwd: header.cwd, - path, - parentSessionPath: header.parentSession, - metadata: header.metadata, - }; -} - -export async function loadJsonlSessionMetadata( - fs: JsonlSessionFileSystem, - path: string, -): Promise { - const lines = getFileSystemResultOrThrow( - await fs.readTextLines(path, { maxLines: 1 }), - `Failed to read session header ${path}`, - ); - if (!lines[0]?.trim()) throw invalidSession(path, "missing session header"); - return metadataFromHeader(parseHeader(lines[0], path), path); -} - -async function loadJsonlSession(fs: JsonlSessionFileSystem, path: string): Promise { - const content = getFileSystemResultOrThrow(await fs.readTextFile(path), `Failed to read session ${path}`); - const lines = content.split("\n").filter((line) => line.trim()); - if (lines.length === 0) throw invalidSession(path, "missing session header"); - const header = parseHeader(lines[0]!, path); - const entries = lines.slice(1).map((line, index) => parseEntry(line, path, index + 2)); - const entryIds = new Set(); - for (const entry of entries) { - if (entryIds.has(entry.id)) throw invalidSession(path, `duplicate entry id ${entry.id}`); - entryIds.add(entry.id); - } - return { - metadata: metadataFromHeader(header, path), - entries, - }; -} - -function encodeCwd(cwd: string): string { - return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; -} - -function createDocumentDescriptor(options: JsonlSessionCreateOptions): SessionDocumentDescriptor { - const id = options.id ?? createSessionId(); - if (!id) throw new SessionError("invalid_session", "Session id cannot be empty"); - let encodedId: string; - try { - encodedId = encodeURIComponent(id); - } catch (error) { - throw new SessionError("invalid_session", `Invalid session id ${JSON.stringify(id)}`, toError(error)); - } - const timestamp = createTimestamp(); - const fileName = `${timestamp.replace(/[:.]/g, "-")}_${encodedId}.jsonl`; - return { - id, - timestamp, - fileName, - operationKey: `document:${JSON.stringify([encodeCwd(options.cwd), fileName])}`, - }; -} - -export class JsonlSessionBackend { - private readonly fs: JsonlSessionRepositoryFileSystem; - private readonly sessionsRootInput: string; - private sessionsRoot: string | undefined; - private readonly entryIndexesByPath = new Map(); - private readonly operationKeysByPath = new Map(); - private readonly operations: KeyedOperationQueue; - private disposed = false; - private disposePromise: Promise | undefined; - - constructor(options: JsonlSessionBackendOptions) { - this.fs = options.fs; - this.sessionsRootInput = options.sessionsRoot; - this.operations = new KeyedOperationQueue({ - maxConcurrentOperations: options.maxConcurrentOperations ?? DEFAULT_MAX_CONCURRENT_OPERATIONS, - }); - } - - create(options: JsonlSessionCreateOptions): Promise> { - this.assertOpen(); - const descriptor = createDocumentDescriptor(options); - return this.operations.enqueue(descriptor.operationKey, async () => - this.storage(await this.createDocument(descriptor, options, options.parentSessionPath, options.metadata, [])), - ); - } - - open(metadata: JsonlSessionMetadata): Promise> { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), async () => - this.storage(await this.loadDocument(metadata)), - ); - } - - private async loadDocument(metadata: JsonlSessionMetadata): Promise { - if ( - !getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`) - ) { - throw new SessionError("not_found", `Session not found: ${metadata.path}`); - } - const document = await loadJsonlSession(this.fs, metadata.path); - const entries = this.entryIndexesByPath.get(metadata.path); - if (entries) entries.replace(document.entries); - else this.entryIndexesByPath.set(metadata.path, new ArraySessionIndex(document.entries)); - return document.metadata; - } - - list(options: JsonlSessionListOptions = {}): Promise { - this.assertOpen(); - return this.operations.enqueueBarrier(() => this.listSessions(options)); - } - - private async listSessions(options: JsonlSessionListOptions): Promise { - const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs(); - const sessions: JsonlSessionMetadata[] = []; - for (const dir of dirs) { - if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) - continue; - const files = getFileSystemResultOrThrow( - await this.fs.listDir(dir), - `Failed to list sessions in ${dir}`, - ).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl")); - for (const file of files) sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); - } - return sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - } - - private appendEntry(metadata: JsonlSessionMetadata, entry: SessionTreeEntry): Promise { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), async () => { - if ( - !getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`) - ) { - throw new SessionError("not_found", `Session not found: ${metadata.path}`); - } - let entries = this.entryIndexesByPath.get(metadata.path); - if (!entries) { - await this.loadDocument(metadata); - entries = this.entryIndexesByPath.get(metadata.path)!; - } - if (entries.has(entry.id)) throw new SessionError("invalid_entry", `Entry ${entry.id} already exists`); - getFileSystemResultOrThrow( - await this.fs.appendFile(metadata.path, `${JSON.stringify(entry)}\n`), - `Failed to append session entry ${entry.id}`, - ); - entries.append(entry); - }); - } - - delete(metadata: JsonlSessionMetadata): Promise { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), async () => { - getFileSystemResultOrThrow( - await this.fs.remove(metadata.path, { force: true }), - `Failed to delete session ${metadata.path}`, - ); - this.entryIndexesByPath.delete(metadata.path); - this.operationKeysByPath.delete(metadata.path); - }); - } - - fork( - source: JsonlSessionMetadata, - options: JsonlSessionCreateOptions, - selection: SessionForkSelection, - ): Promise> { - this.assertOpen(); - const descriptor = createDocumentDescriptor(options); - const sourceEntries = this.operations.enqueue(this.operationKey(source), async () => { - if (!getFileSystemResultOrThrow(await this.fs.exists(source.path), `Failed to check session ${source.path}`)) { - throw new SessionError("not_found", `Session not found: ${source.path}`); - } - const document = await loadJsonlSession(this.fs, source.path); - const entries = this.entryIndexesByPath.get(source.path); - if (entries) entries.replace(document.entries); - else this.entryIndexesByPath.set(source.path, new ArraySessionIndex(document.entries)); - return readSessionEntriesForFork(this.entryIndexesByPath.get(source.path)!, selection); - }); - return this.operations.enqueue(descriptor.operationKey, async () => - this.storage( - await this.createDocument( - descriptor, - options, - options.parentSessionPath ?? source.path, - options.metadata ?? source.metadata, - await sourceEntries, - ), - ), - ); - } - - async [Symbol.asyncDispose](): Promise { - if (!this.disposePromise) { - this.disposed = true; - this.disposePromise = this.operations.drain(); - } - await this.disposePromise; - } - - private assertOpen(): void { - if (this.disposed) throw new SessionError("storage", "JSONL session repository is disposed"); - } - - private operationKey(metadata: JsonlSessionMetadata): string { - return this.operationKeysByPath.get(metadata.path) ?? metadata.path; - } - - private async createDocument( - descriptor: SessionDocumentDescriptor, - options: JsonlSessionCreateOptions, - parentSessionPath: string | undefined, - metadata: Record | undefined, - entries: readonly SessionTreeEntry[], - ): Promise { - const dir = await this.getSessionDir(options.cwd); - getFileSystemResultOrThrow( - await this.fs.createDir(dir, { recursive: true }), - `Failed to create session directory ${dir}`, - ); - const path = getFileSystemResultOrThrow( - await this.fs.joinPath([dir, descriptor.fileName]), - `Failed to resolve session file path for ${descriptor.id}`, - ); - if (getFileSystemResultOrThrow(await this.fs.exists(path), `Failed to check session ${path}`)) { - throw new SessionError("invalid_session", `Session already exists: ${path}`); - } - const header: SessionHeader = { - type: "session", - version: 3, - id: descriptor.id, - timestamp: descriptor.timestamp, - cwd: options.cwd, - parentSession: parentSessionPath, - metadata, - }; - const content = [JSON.stringify(header), ...entries.map((entry) => JSON.stringify(entry)), ""].join("\n"); - getFileSystemResultOrThrow(await this.fs.writeFile(path, content), `Failed to create session ${path}`); - this.entryIndexesByPath.set(path, new ArraySessionIndex(entries)); - this.operationKeysByPath.set(path, descriptor.operationKey); - return metadataFromHeader(header, path); - } - - private readIndex(metadata: JsonlSessionMetadata, read: (entries: ArraySessionIndex) => T): Promise { - this.assertOpen(); - return this.operations.enqueue(this.operationKey(metadata), () => read(this.entryIndex(metadata.path))); - } - - private storage(metadata: JsonlSessionMetadata): SessionStorage { - return { - metadata, - readHead: () => this.readIndex(metadata, (entries) => entries.readHead()), - readEntry: (id) => this.readIndex(metadata, (entries) => entries.readEntry(id)), - readEntries: (options) => this.readIndex(metadata, (entries) => entries.readEntries(options)), - appendEntry: (entry) => this.appendEntry(metadata, entry), - findEntriesOnBranch: (query) => this.readIndex(metadata, (entries) => entries.findEntriesOnBranch(query)), - readPathToRootOrCompaction: (leafId) => - this.readIndex(metadata, (entries) => entries.readPathToRootOrCompaction(leafId)), - getLabel: (id) => this.readIndex(metadata, (entries) => entries.getLabel(id)), - getName: () => this.readIndex(metadata, (entries) => entries.getName()), - getStats: () => this.readIndex(metadata, (entries) => entries.getStats()), - }; - } - - private entryIndex(path: string): ArraySessionIndex { - const entries = this.entryIndexesByPath.get(path); - if (!entries) throw new SessionError("not_found", `Session not found: ${path}`); - return entries; - } - - private async getSessionsRoot(): Promise { - this.sessionsRoot ??= getFileSystemResultOrThrow( - await this.fs.absolutePath(this.sessionsRootInput), - `Failed to resolve sessions root ${this.sessionsRootInput}`, - ); - return this.sessionsRoot; - } - - private async getSessionDir(cwd: string): Promise { - return getFileSystemResultOrThrow( - await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]), - `Failed to resolve session directory for ${cwd}`, - ); - } - - private async listSessionDirs(): Promise { - const root = await this.getSessionsRoot(); - if (!getFileSystemResultOrThrow(await this.fs.exists(root), `Failed to check sessions root ${root}`)) return []; - return getFileSystemResultOrThrow(await this.fs.listDir(root), `Failed to list sessions root ${root}`) - .filter((entry) => entry.kind === "directory") - .map((entry) => entry.path); - } -} - -export interface JsonlSessionRepositoryOptions extends JsonlSessionBackendOptions { - contextBuildOptions?: SessionContextBuildOptions; -} - -export class JsonlSessionRepository - implements SessionRepository -{ - private readonly backend: JsonlSessionBackend; - private readonly contextBuildOptions: SessionContextBuildOptions; - - constructor(options: JsonlSessionRepositoryOptions) { - const { contextBuildOptions, ...backendOptions } = options; - this.backend = new JsonlSessionBackend(backendOptions); - this.contextBuildOptions = contextBuildOptions ?? {}; - } - - async create(options: JsonlSessionCreateOptions): Promise> { - return createSession(await this.backend.create(options), this.contextBuildOptions); - } - - async open(metadata: JsonlSessionMetadata): Promise> { - return createSession(await this.backend.open(metadata), this.contextBuildOptions); - } - - async list(options?: JsonlSessionListOptions): Promise { - return await this.backend.list(options); - } - - async delete(metadata: JsonlSessionMetadata): Promise { - await this.backend.delete(metadata); - } - - async fork( - source: JsonlSessionMetadata, - options: SessionForkOptions & JsonlSessionCreateOptions, - ): Promise> { - const { entryId: _entryId, position: _position, ...createOptions } = options; - return createSession( - await this.backend.fork(source, createOptions, createSessionForkSelection(options)), - this.contextBuildOptions, - ); - } - - async [Symbol.asyncDispose](): Promise { - await this.backend[Symbol.asyncDispose](); - } -} diff --git a/packages/agent/src/harness/session/keyed-operation-queue.ts b/packages/agent/src/harness/session/keyed-operation-queue.ts deleted file mode 100644 index 8cfc678926a..00000000000 --- a/packages/agent/src/harness/session/keyed-operation-queue.ts +++ /dev/null @@ -1,69 +0,0 @@ -export class KeyedOperationQueue { - private readonly tails = new Map>(); - private readonly maxConcurrentOperations: number | undefined; - private readonly permitWaiters: Array<() => void> = []; - private activeOperations = 0; - private barrier: Promise = Promise.resolve(); - - constructor(options: { maxConcurrentOperations?: number } = {}) { - if ( - options.maxConcurrentOperations !== undefined && - (!Number.isInteger(options.maxConcurrentOperations) || options.maxConcurrentOperations < 1) - ) { - throw new RangeError("maxConcurrentOperations must be a positive integer"); - } - this.maxConcurrentOperations = options.maxConcurrentOperations; - } - - enqueue(key: TKey, operation: () => Promise | T): Promise { - const previous = this.tails.get(key) ?? Promise.resolve(); - const result = Promise.all([this.barrier, previous]).then(() => this.runOperation(operation)); - const tail = result.then( - () => undefined, - () => undefined, - ); - this.tails.set(key, tail); - void tail.then(() => { - if (this.tails.get(key) === tail) this.tails.delete(key); - }); - return result; - } - - enqueueBarrier(operation: () => Promise | T): Promise { - const result = Promise.all([this.barrier, ...this.tails.values()]).then(() => this.runOperation(operation)); - this.barrier = result.then( - () => undefined, - () => undefined, - ); - return result; - } - - async drain(): Promise { - await Promise.all([this.barrier, ...this.tails.values()]); - } - - private async runOperation(operation: () => Promise | T): Promise { - await this.acquirePermit(); - try { - return await operation(); - } finally { - this.releasePermit(); - } - } - - private async acquirePermit(): Promise { - if (this.maxConcurrentOperations === undefined) return; - if (this.activeOperations < this.maxConcurrentOperations) { - this.activeOperations += 1; - return; - } - await new Promise((resolve) => this.permitWaiters.push(resolve)); - } - - private releasePermit(): void { - if (this.maxConcurrentOperations === undefined) return; - const next = this.permitWaiters.shift(); - if (next) next(); - else this.activeOperations -= 1; - } -} diff --git a/packages/agent/src/harness/session/memory-repo.ts b/packages/agent/src/harness/session/memory-repo.ts deleted file mode 100644 index b28e0369e8f..00000000000 --- a/packages/agent/src/harness/session/memory-repo.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { - SessionForkOptions, - SessionForkSelection, - SessionMetadata, - SessionStorage, - SessionTreeEntry, -} from "../types.ts"; -import { SessionError } from "../types.ts"; -import { ArraySessionIndex } from "./array-session-index.ts"; -import { KeyedOperationQueue } from "./keyed-operation-queue.ts"; -import { - createSessionForkSelection, - createSessionId, - createTimestamp, - readSessionEntriesForFork, - type SessionRepository, -} from "./repository.ts"; -import { createSession, type Session, type SessionContextBuildOptions } from "./session.ts"; - -export type InMemorySessionCreateOptions = { id?: string }; - -interface InMemorySessionState { - metadata: SessionMetadata; - entries: ArraySessionIndex; -} - -export class InMemorySessionBackend { - private readonly sessions = new Map(); - private readonly operations = new KeyedOperationQueue(); - private disposed = false; - private disposePromise: Promise | undefined; - - create(options: InMemorySessionCreateOptions = {}): Promise> { - this.assertOpen(); - const id = options.id ?? createSessionId(); - return this.operations.enqueue(id, () => { - const state: InMemorySessionState = { - metadata: { id, createdAt: createTimestamp() }, - entries: new ArraySessionIndex(), - }; - this.sessions.set(id, state); - return this.storage(state); - }); - } - - open(metadata: SessionMetadata): Promise> { - this.assertOpen(); - return this.operations.enqueue(metadata.id, () => this.storage(this.getState(metadata))); - } - - list(): Promise { - this.assertOpen(); - return this.operations.enqueueBarrier(() => [...this.sessions.values()].map((state) => state.metadata)); - } - - delete(metadata: SessionMetadata): Promise { - this.assertOpen(); - return this.operations.enqueue(metadata.id, () => { - this.sessions.delete(metadata.id); - }); - } - - fork( - source: SessionMetadata, - options: InMemorySessionCreateOptions, - selection: SessionForkSelection, - ): Promise> { - this.assertOpen(); - const id = options.id ?? createSessionId(); - const sourceEntries = this.operations.enqueue(source.id, () => - readSessionEntriesForFork(this.getState(source).entries, selection), - ); - return this.operations.enqueue(id, async () => { - const state: InMemorySessionState = { - metadata: { id, createdAt: createTimestamp() }, - entries: new ArraySessionIndex(await sourceEntries), - }; - this.sessions.set(id, state); - return this.storage(state); - }); - } - - async [Symbol.asyncDispose](): Promise { - if (!this.disposePromise) { - this.disposed = true; - this.disposePromise = this.operations.drain(); - } - await this.disposePromise; - } - - private storage(state: InMemorySessionState): SessionStorage { - const read = (operation: (entries: ArraySessionIndex) => T): Promise => { - this.assertOpen(); - return this.operations.enqueue(state.metadata.id, () => operation(this.getState(state.metadata).entries)); - }; - return { - metadata: state.metadata, - readHead: () => read((entries) => entries.readHead()), - readEntry: (id) => read((entries) => entries.readEntry(id)), - readEntries: (options) => read((entries) => entries.readEntries(options)), - appendEntry: (entry) => this.appendEntry(state.metadata, entry), - findEntriesOnBranch: (query) => read((entries) => entries.findEntriesOnBranch(query)), - readPathToRootOrCompaction: (leafId) => read((entries) => entries.readPathToRootOrCompaction(leafId)), - getLabel: (id) => read((entries) => entries.getLabel(id)), - getName: () => read((entries) => entries.getName()), - getStats: () => read((entries) => entries.getStats()), - }; - } - - private appendEntry(metadata: SessionMetadata, entry: SessionTreeEntry): Promise { - this.assertOpen(); - return this.operations.enqueue(metadata.id, () => { - this.getState(metadata).entries.append(entry); - }); - } - - private assertOpen(): void { - if (this.disposed) throw new SessionError("storage", "In-memory session repository is disposed"); - } - - private getState(metadata: SessionMetadata): InMemorySessionState { - const state = this.sessions.get(metadata.id); - if (!state) throw new SessionError("not_found", `Session not found: ${metadata.id}`); - return state; - } -} - -export interface InMemorySessionRepositoryOptions { - contextBuildOptions?: SessionContextBuildOptions; -} - -export class InMemorySessionRepository - implements SessionRepository -{ - private readonly backend = new InMemorySessionBackend(); - private readonly contextBuildOptions: SessionContextBuildOptions; - - constructor(options: InMemorySessionRepositoryOptions = {}) { - this.contextBuildOptions = options.contextBuildOptions ?? {}; - } - - async create(options: InMemorySessionCreateOptions = {}): Promise> { - return createSession(await this.backend.create(options), this.contextBuildOptions); - } - - async open(metadata: SessionMetadata): Promise> { - return createSession(await this.backend.open(metadata), this.contextBuildOptions); - } - - async list(): Promise { - return await this.backend.list(); - } - - async delete(metadata: SessionMetadata): Promise { - await this.backend.delete(metadata); - } - - async fork( - source: SessionMetadata, - options: SessionForkOptions & InMemorySessionCreateOptions, - ): Promise> { - const { entryId: _entryId, position: _position, ...createOptions } = options; - return createSession( - await this.backend.fork(source, createOptions, createSessionForkSelection(options)), - this.contextBuildOptions, - ); - } - - async [Symbol.asyncDispose](): Promise { - await this.backend[Symbol.asyncDispose](); - } -} diff --git a/packages/agent/src/harness/experimental/session/memory.ts b/packages/agent/src/harness/session/memory.ts similarity index 100% rename from packages/agent/src/harness/experimental/session/memory.ts rename to packages/agent/src/harness/session/memory.ts diff --git a/packages/agent/src/harness/session/repository.ts b/packages/agent/src/harness/session/repository.ts deleted file mode 100644 index 6a09a827729..00000000000 --- a/packages/agent/src/harness/session/repository.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { uuidv7 } from "@cheetahbyte/pi-ai"; -import { - type FileError, - type Result, - type SessionCreateOptions, - SessionError, - type SessionForkOptions, - type SessionForkSelection, - type SessionMetadata, - type SessionTreeEntry, -} from "../types.ts"; -import type { Session } from "./session.ts"; - -export function createSessionId(): string { - return uuidv7(); -} - -export function createTimestamp(): string { - return new Date().toISOString(); -} - -export interface SessionRepository< - TMetadata extends SessionMetadata = SessionMetadata, - TCreateOptions extends SessionCreateOptions = SessionCreateOptions, - TListOptions = void, -> extends AsyncDisposable { - create(options: TCreateOptions): Promise>; - open(metadata: TMetadata): Promise>; - list(options?: TListOptions): Promise; - delete(metadata: TMetadata): Promise; - fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise>; -} - -export function getFileSystemResultOrThrow(result: Result, message: string): TValue { - if (!result.ok) { - const code = result.error.code === "not_found" ? "not_found" : "storage"; - throw new SessionError(code, `${message}: ${result.error.message}`, result.error); - } - return result.value; -} - -type MaybePromise = T | Promise; - -interface SessionForkEntrySource { - readEntry(id: string): MaybePromise; - readEntries(): MaybePromise; - readPathToRootOrCompaction(leafId: string | null): MaybePromise; -} - -/** @internal Transitional fork selection shared by built-in repositories. */ -export function createSessionForkSelection(options: SessionForkOptions): SessionForkSelection { - if (!options.entryId) return { kind: "all" }; - return (options.position ?? "before") === "at" - ? { kind: "through_entry", entryId: options.entryId } - : { kind: "before_user_message", entryId: options.entryId }; -} - -/** @internal Shared fork selection validation for built-in repositories. */ -export async function readSessionEntriesForFork( - source: SessionForkEntrySource, - selection: SessionForkSelection, -): Promise { - if (selection.kind === "all") return source.readEntries(); - const target = await source.readEntry(selection.entryId); - if (!target) throw new SessionError("invalid_fork_target", `Entry ${selection.entryId} not found`); - if (selection.kind === "through_entry") return source.readPathToRootOrCompaction(target.id); - if (target.type !== "message" || target.message.role !== "user") { - throw new SessionError("invalid_fork_target", `Entry ${selection.entryId} is not a user message`); - } - return source.readPathToRootOrCompaction(target.parentId); -} diff --git a/packages/agent/src/harness/session/search.ts b/packages/agent/src/harness/session/search.ts index e4eaebf1816..0bd44c44cc1 100644 --- a/packages/agent/src/harness/session/search.ts +++ b/packages/agent/src/harness/session/search.ts @@ -1,19 +1,37 @@ -import type { - SessionCreateOptions, - SessionMetadata, - SessionSearch, - SessionSearchHit, - SessionSearchOptions, -} from "../types.ts"; -import type { SessionRepository } from "./repository.ts"; +import type { FileError, Result } from "../types.ts"; import type { Session } from "./session.ts"; +import { type SessionCreateOptions, SessionError, type SessionMetadata, type SessionRepo } from "./types.ts"; + +export interface SessionSearchOptions { + text: string; + cwd?: string; +} + +export interface SessionSearchHit { + metadata: TMetadata; + entryId: string; + timestamp: string; + snippet?: string; + score?: number; +} + +export interface SessionSearch { + search(options: SessionSearchOptions): Promise[]>; +} + +export function getFileSystemResultOrThrow(result: Result, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} type ScanningSessionSearchSource = { list(): Promise; open(metadata: TMetadata): Promise>; }; -/** Searches canonical sessions directly and therefore has no index to maintain. */ class ScanningSessionSearch implements SessionSearch { private readonly source: ScanningSessionSearchSource; @@ -29,10 +47,15 @@ class ScanningSessionSearch const cwd = (metadata as { cwd?: unknown }).cwd; if (options.cwd !== undefined && cwd !== options.cwd) continue; const session = await this.source.open(metadata); - for (const entry of await session.getEntries()) { + for (const entry of await session.findEntries({ order: "oldestFirst" })) { const payload = JSON.stringify(entry); if (!payload.toLowerCase().includes(normalizedText)) continue; - hits.push({ metadata, entryId: entry.id, timestamp: entry.timestamp, snippet: payload }); + hits.push({ + metadata, + entryId: entry.id, + timestamp: new Date(entry.timestamp).toISOString(), + snippet: payload, + }); } } return hits; @@ -43,6 +66,6 @@ export function createScanningSessionSearch< TMetadata extends SessionMetadata, TCreateOptions extends SessionCreateOptions, TListOptions, ->(source: Pick, "list" | "open">): SessionSearch { +>(source: Pick, "list" | "open">): SessionSearch { return new ScanningSessionSearch(source); } diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 3915c7fefcd..f0e0fef4290 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -1,430 +1,285 @@ -import { type ImageContent, type TextContent, type Usage, uuidv7 } from "@cheetahbyte/pi-ai"; +import { uuidv7 } from "@cheetahbyte/pi-ai"; import type { AgentMessage } from "../../types.ts"; -import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; import type { - ActiveToolsChangeEntry, - BranchSummaryEntry, - CompactionEntry, - CustomEntry, - CustomMessageEntry, - LabelEntry, - LeafEntry, - MessageEntry, - ModelChangeEntry, - SessionBranchQuery, - SessionContext, - SessionEntryCursorOptions, - SessionInfoEntry, + BranchBounds, + Entry, + EntryQuery, + IdGenerator, + LanePointer, + LaneRecord, + LogItem, + LogOptions, + NewRecord, + ProvisionedEntry, + RecordBase, + RecordQuery, SessionMetadata, SessionStats, SessionStorage, - SessionTreeEntry, - ThinkingLevelChangeEntry, -} from "../types.ts"; -import { SessionError } from "../types.ts"; - -export type ContextEntryTransform = (entries: readonly SessionTreeEntry[]) => readonly SessionTreeEntry[]; - -export type CustomEntryContextMessageProjector = ( - entry: CustomEntry, - index: number, - entries: readonly SessionTreeEntry[], -) => readonly AgentMessage[] | undefined; - -export interface SessionContextBuildOptions { - /** Additional entry transforms applied after the default compaction transform. */ - entryTransforms?: readonly ContextEntryTransform[]; - /** Optional custom-entry projectors. Custom entries are omitted from model context by default. */ - entryProjectors?: Readonly>; -} + SessionTree, +} from "./types.ts"; +import { SessionError } from "./types.ts"; -function deriveSessionContextState(pathEntries: readonly SessionTreeEntry[]): Omit { - let thinkingLevel = "off"; - let model: { provider: string; modelId: string } | null = null; - let activeToolNames: string[] | null = null; - - for (const entry of pathEntries) { - if (entry.type === "thinking_level_change") { - thinkingLevel = entry.thinkingLevel; - } else if (entry.type === "model_change") { - model = { provider: entry.provider, modelId: entry.modelId }; - } else if (entry.type === "message" && entry.message.role === "assistant") { - model = { provider: entry.message.provider, modelId: entry.message.model }; - } else if (entry.type === "active_tools_change") { - activeToolNames = [...entry.activeToolNames]; - } - } +type JsonValidationFrame = { value: unknown } | { exit: object }; - return { thinkingLevel, model, activeToolNames }; +function invalidPayload(reason: string): never { + throw new SessionError("invalid_payload", `Durable payload ${reason}`); } -export function defaultContextEntryTransform(pathEntries: readonly SessionTreeEntry[]): SessionTreeEntry[] { - let compaction: CompactionEntry | null = null; - for (const entry of pathEntries) { - if (entry.type === "compaction") { - compaction = entry; - } - } - if (!compaction) { - return [...pathEntries]; - } - - const entries: SessionTreeEntry[] = [compaction]; - const compactionIdx = pathEntries.findIndex((entry) => entry.type === "compaction" && entry.id === compaction.id); - if (compaction.retainedTail) { - for (let i = compactionIdx + 1; i < pathEntries.length; i++) { - entries.push(pathEntries[i]!); - } - return entries; - } - if (compaction.firstKeptEntryId) { - let foundFirstKept = false; - for (let i = 0; i < compactionIdx; i++) { - const entry = pathEntries[i]!; - if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true; - if (foundFirstKept) entries.push(entry); - } - } - for (let i = compactionIdx + 1; i < pathEntries.length; i++) { - entries.push(pathEntries[i]!); +function assertValidLimit(limit: number | undefined): void { + if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) { + throw new SessionError("invalid_query", "limit must be a positive integer"); } - return entries; } -export function buildContextEntries( - pathEntries: readonly SessionTreeEntry[], - options: SessionContextBuildOptions = {}, -): SessionTreeEntry[] { - let entries = defaultContextEntryTransform(pathEntries); - for (const transform of options.entryTransforms ?? []) { - entries = [...transform(entries)]; +function assertValidCursor(afterSeq: number | undefined): void { + if (afterSeq !== undefined && (!Number.isInteger(afterSeq) || afterSeq < 0)) { + throw new SessionError("invalid_query", "cursor sequence must be a non-negative integer"); } - return entries; } -export function sessionEntryToContextMessages( - entry: SessionTreeEntry, - index: number, - entries: readonly SessionTreeEntry[], - options: SessionContextBuildOptions = {}, -): AgentMessage[] { - if (entry.type === "message") { - return [entry.message as AgentMessage]; - } - if (entry.type === "custom_message") { - return [ - createCustomMessage( - entry.customType, - entry.content as string | (TextContent | ImageContent)[], - entry.display, - entry.details, - entry.timestamp, - ), - ]; - } - if (entry.type === "compaction") { - return [ - createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), - ...(entry.retainedTail ?? []), - ]; - } - if (entry.type === "branch_summary" && entry.summary) { - return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; - } - if (entry.type === "custom") { - return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])]; - } - return []; -} +export function assertJsonSerializable(value: unknown): void { + const active = new WeakSet(); + const stack: JsonValidationFrame[] = [{ value }]; + while (stack.length > 0) { + const frame = stack.pop()!; + if ("exit" in frame) { + active.delete(frame.exit); + continue; + } + const candidate = frame.value; + if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") { + continue; + } + if (typeof candidate === "number") { + if (!Number.isFinite(candidate)) invalidPayload("contains a non-finite number"); + continue; + } + if (typeof candidate !== "object") invalidPayload(`contains ${typeof candidate}`); + if (active.has(candidate)) invalidPayload("contains a cycle"); + active.add(candidate); + stack.push({ exit: candidate }); + + if (Array.isArray(candidate)) { + if (Object.getPrototypeOf(candidate) !== Array.prototype) { + invalidPayload("contains a non-standard array"); + } + if ( + Object.getOwnPropertySymbols(candidate).length > 0 || + Object.getOwnPropertyNames(candidate).length !== candidate.length + 1 + ) { + invalidPayload("contains an array with unsupported properties"); + } + for (let index = candidate.length - 1; index >= 0; index--) { + if (!Object.hasOwn(candidate, index)) invalidPayload("contains a sparse array"); + const descriptor = Object.getOwnPropertyDescriptor(candidate, index)!; + if (!("value" in descriptor)) invalidPayload("contains an array accessor"); + stack.push({ value: descriptor.value }); + } + continue; + } -export function buildSessionContext( - pathEntries: readonly SessionTreeEntry[], - options: SessionContextBuildOptions = {}, -): SessionContext { - const state = deriveSessionContextState(pathEntries); - const contextEntries = buildContextEntries(pathEntries, options); - const messages = contextEntries.flatMap((entry, index) => - sessionEntryToContextMessages(entry, index, contextEntries, options), - ); - return { ...state, messages }; + const prototype = Object.getPrototypeOf(candidate); + if (prototype !== Object.prototype && prototype !== null) { + invalidPayload("contains a non-plain object"); + } + if (Object.getOwnPropertySymbols(candidate).length > 0) { + invalidPayload("contains a symbol-keyed property"); + } + const keys = Object.keys(candidate); + if (Object.getOwnPropertyNames(candidate).length !== keys.length) { + invalidPayload("contains a non-enumerable property"); + } + for (let index = keys.length - 1; index >= 0; index--) { + const descriptor = Object.getOwnPropertyDescriptor(candidate, keys[index]!)!; + if (!("value" in descriptor)) invalidPayload("contains an accessor"); + stack.push({ value: descriptor.value }); + } + } } -export class Session { +export class Session implements SessionTree { private readonly storage: SessionStorage; - private readonly metadata: TMetadata; - private leafId: string | null; - private readonly contextBuildOptions: SessionContextBuildOptions; - private appendTail: Promise = Promise.resolve(); - - /** @internal Construct sessions through SessionRepository. */ - constructor( - storage: SessionStorage, - leafId: string | null, - contextBuildOptions: SessionContextBuildOptions = {}, - ) { + readonly idGenerator: IdGenerator; + + constructor(storage: SessionStorage, options: { idGenerator?: IdGenerator } = {}) { this.storage = storage; - this.metadata = storage.metadata; - this.leafId = leafId; - this.contextBuildOptions = contextBuildOptions; + this.idGenerator = options.idGenerator ?? { next: () => uuidv7() }; } async getMetadata(): Promise { - return this.metadata; + return this.storage.getMetadata(); } + + view(lane: string): SessionTree { + if (lane === "main") return this; + return { + getLeafId: () => this.getLeafIdForLane(lane), + getEntry: (id) => this.getEntry(id), + getStats: () => this.getStats(), + getName: () => this.getName(), + setName: (name) => this.setName(name), + getLabel: (targetId) => this.getLabel(targetId), + setLabel: (targetId, label) => this.setLabel(targetId, label), + findEntries: (query) => this.queryEntries(query), + findEntry: async (query = {}) => (await this.queryEntries(query, 1))[0], + findEntriesOnBranch: (query) => this.queryBranchEntries(lane, query), + findEntryOnBranch: async (query = {}) => (await this.queryBranchEntries(lane, query, 1))[0], + appendMessage: (message) => this.appendMessageToLane(lane, message), + appendCustomEntry: (customType, data) => this.appendCustomEntryToLane(lane, customType, data), + }; + } + async getLeafId(): Promise { - return this.leafId; + return this.getLeafIdForLane("main"); } - async getEntry(id: string): Promise { - return this.storage.readEntry(id); + + async getEntry(id: string): Promise { + return this.storage.getEntry(id); } - async getEntries(options?: SessionEntryCursorOptions): Promise { - return [...(await this.storage.readEntries(options))]; + + async getStats(): Promise { + return this.storage.getStats(); } - async getBranch(fromId?: string | null): Promise { - return [...(await this.storage.readPathToRootOrCompaction(fromId === undefined ? this.leafId : fromId))]; + async getName(): Promise { + return this.storage.getName(); } - async findEntriesOnBranch(query: SessionBranchQuery = {}): Promise { - return [ - ...(await this.storage.findEntriesOnBranch({ - ...query, - start: query.start === undefined ? this.leafId : query.start, - })), - ]; + async setName(name: string): Promise { + await this.storage.setName(name); } - async findEntryOnBranch(query: SessionBranchQuery = {}): Promise { - return (await this.findEntriesOnBranch({ ...query, limit: 1 }))[0]; + async getLabel(targetId: string): Promise { + return this.storage.getLabel(targetId); } - async buildContextEntries(options: SessionContextBuildOptions = {}): Promise { - return buildContextEntries(await this.getBranch(), this.mergeContextBuildOptions(options)); + async setLabel(targetId: string, label: string | undefined): Promise { + await this.storage.setLabel(targetId, label); } - async buildContext(options: SessionContextBuildOptions = {}): Promise { - return buildSessionContext(await this.getBranch(), this.mergeContextBuildOptions(options)); + async findEntries(query?: EntryQuery): Promise { + return this.queryEntries(query); } - private mergeContextBuildOptions(options: SessionContextBuildOptions): SessionContextBuildOptions { - return { - entryTransforms: [...(this.contextBuildOptions.entryTransforms ?? []), ...(options.entryTransforms ?? [])], - entryProjectors: { - ...(this.contextBuildOptions.entryProjectors ?? {}), - ...(options.entryProjectors ?? {}), - }, - }; + async findEntry(query: EntryQuery = {}): Promise { + return (await this.queryEntries(query, 1))[0]; } - async getLabel(id: string): Promise { - return this.storage.getLabel(id); + async findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise { + return this.queryBranchEntries("main", query); } - async getSessionStats(): Promise { - return this.storage.getStats(); + + async findEntryOnBranch(query: EntryQuery & BranchBounds = {}): Promise { + return (await this.queryBranchEntries("main", query, 1))[0]; } - async getSessionName(): Promise { - return this.storage.getName(); + + async appendMessage(message: AgentMessage): Promise { + return this.appendMessageToLane("main", message); } - private async createEntryId(): Promise { - for (let i = 0; i < 100; i++) { - const id = uuidv7().slice(-8); - if (!(await this.getEntry(id))) return id; - } - return uuidv7(); - } - - private enqueueAppend( - createEntry: (base: Pick) => TEntry, - ): Promise { - const commit = this.appendTail.then(async () => { - const entry = createEntry({ - id: await this.createEntryId(), - parentId: this.leafId, - timestamp: new Date().toISOString(), - }); - await this.storage.appendEntry(entry); - this.leafId = entry.type === "leaf" ? entry.targetId : entry.id; - return entry; - }); - this.appendTail = commit.then( - () => undefined, - () => undefined, - ); - return commit; + async appendCustomEntry(customType: string, data?: unknown): Promise { + return this.appendCustomEntryToLane("main", customType, data); } - private async setLeafId(leafId: string | null): Promise { - if (leafId !== null && !(await this.getEntry(leafId))) { - throw new SessionError("not_found", `Entry ${leafId} not found`); - } - return this.enqueueAppend((base) => { - return { ...base, type: "leaf", targetId: leafId }; - }); + async getLanes(): Promise { + return this.storage.getLanes(); } - private async appendTypedEntry( - createEntry: (base: Pick) => TEntry, - ): Promise { - return (await this.enqueueAppend(createEntry)).id; + async createLane(lane: string, at: string | null): Promise { + await this.storage.createLane(lane, at); } - async appendMessage(message: AgentMessage): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "message", - message, - }) satisfies MessageEntry, - ); + async moveLane(lane: string, to: string | null): Promise { + await this.storage.moveLane(lane, to); } - async appendThinkingLevelChange(thinkingLevel: string): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "thinking_level_change", - thinkingLevel, - }) satisfies ThinkingLevelChangeEntry, - ); + async appendEntry(entry: ProvisionedEntry, lane: string): Promise { + return this.commitEntry(entry, lane); } - async appendModelChange(provider: string, modelId: string): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "model_change", - provider, - modelId, - }) satisfies ModelChangeEntry, - ); + async appendRecord( + record: TNewRecord, + ): Promise>; + async appendRecord(record: NewRecord): Promise { + return this.commitRecord(record); } - async appendActiveToolsChange(activeToolNames: string[]): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "active_tools_change", - activeToolNames: [...activeToolNames], - }) satisfies ActiveToolsChangeEntry, - ); + async findRecords( + query: RecordQuery & { type: K }, + ): Promise[]>; + async findRecords(query?: RecordQuery): Promise; + async findRecords(query?: RecordQuery): Promise { + return this.queryRecords(query); } - async appendCompaction( - summary: string, - firstKeptEntryId: string | undefined, - tokensBefore: number, - details?: T, - fromHook?: boolean, - usage?: Usage, - retainedTail?: AgentMessage[], - ): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "compaction", - summary, - firstKeptEntryId, - tokensBefore, - retainedTail, - details, - usage, - fromHook, - }) satisfies CompactionEntry, - ); + async getLog(options?: LogOptions): Promise { + return this.queryLog(options); } - async appendCustomEntry(customType: string, data?: unknown): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "custom", - customType, - data, - }) satisfies CustomEntry, - ); + private async getLeafIdForLane(lane: string): Promise { + const pointer = (await this.storage.getLanes()).find((candidate) => candidate.lane === lane); + if (!pointer) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); + return pointer.leafId; } - async appendCustomMessageEntry( - customType: string, - content: string | (TextContent | ImageContent)[], - display: boolean, - details?: T, - ): Promise { - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "custom_message", - customType, - content, - display, - details, - }) satisfies CustomMessageEntry, - ); + private async queryEntries(query: EntryQuery = {}, resultLimit = query.limit): Promise { + assertValidLimit(query.limit); + assertValidCursor(query.cursor?.afterSeq); + return this.storage.findEntries(resultLimit === query.limit ? query : { ...query, limit: resultLimit }); } - async appendLabel(targetId: string, label: string | undefined): Promise { - if (!(await this.getEntry(targetId))) { - throw new SessionError("not_found", `Entry ${targetId} not found`); - } - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "label", - targetId, - label, - }) satisfies LabelEntry, - ); + private async queryBranchEntries( + lane: string, + query: EntryQuery & BranchBounds = {}, + resultLimit = query.limit, + ): Promise { + assertValidLimit(query.limit); + assertValidCursor(query.cursor?.afterSeq); + const start = query.start ?? (await this.getLeafIdForLane(lane)); + if (start === null) return []; + const storageQuery = resultLimit === query.limit ? query : { ...query, limit: resultLimit }; + return this.storage.findEntriesOnBranch({ ...storageQuery, start }); } - async appendSessionName(name: string): Promise { - const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "session_info", - name: sanitizedName, - }) satisfies SessionInfoEntry, - ); + private async queryRecords(query: RecordQuery = {}): Promise { + assertValidLimit(query.limit); + assertValidCursor(query.afterSeq); + return this.storage.findRecords(query); } - async moveTo( - entryId: string | null, - summary?: { summary: string; details?: unknown; usage?: Usage; fromHook?: boolean }, - ): Promise { - if (entryId !== null && !(await this.getEntry(entryId))) { - throw new SessionError("not_found", `Entry ${entryId} not found`); - } - await this.setLeafId(entryId); - if (!summary) return undefined; - return this.appendTypedEntry( - (base) => - ({ - ...base, - type: "branch_summary", - fromId: entryId ?? "root", - summary: summary.summary, - details: summary.details, - usage: summary.usage, - fromHook: summary.fromHook, - }) satisfies BranchSummaryEntry, + private async queryLog(options: LogOptions = {}): Promise { + assertValidLimit(options.limit); + assertValidCursor(options.afterSeq); + return this.storage.getLog(options); + } + + private async appendMessageToLane(lane: string, message: AgentMessage): Promise { + const entry = await this.commitEntry({ type: "message", id: this.idGenerator.next(), message }, lane); + return entry.id; + } + + private async appendCustomEntryToLane(lane: string, customType: string, data?: unknown): Promise { + const entry = await this.commitEntry( + data === undefined + ? { type: "custom", id: this.idGenerator.next(), customType } + : { type: "custom", id: this.idGenerator.next(), customType, data }, + lane, ); + return entry.id; } -} -/** Wraps an opened storage connection for a SessionRepository implementation. */ -export async function createSession( - storage: SessionStorage, - contextBuildOptions: SessionContextBuildOptions = {}, -): Promise> { - return new Session(storage, (await storage.readHead()).leafId, contextBuildOptions); + private async commitEntry(entry: ProvisionedEntry, lane: string): Promise { + assertJsonSerializable(entry); + return this.storage.appendEntry(entry, lane); + } + + private async commitRecord( + record: TNewRecord, + ): Promise> { + assertJsonSerializable(record); + return this.storage.appendRecord(record) as unknown as Promise< + TNewRecord & Pick + >; + } } diff --git a/packages/agent/src/harness/experimental/session/testing/conformance.ts b/packages/agent/src/harness/session/testing/conformance.ts similarity index 99% rename from packages/agent/src/harness/experimental/session/testing/conformance.ts rename to packages/agent/src/harness/session/testing/conformance.ts index 62d76627ad0..84edad47b43 100644 --- a/packages/agent/src/harness/experimental/session/testing/conformance.ts +++ b/packages/agent/src/harness/session/testing/conformance.ts @@ -1,5 +1,5 @@ import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; -import type { AgentMessage } from "../../../../types.ts"; +import type { AgentMessage } from "../../../types.ts"; import type { CustomEntry, Entry, @@ -466,6 +466,7 @@ export function createSessionBackendConformance( await session.setName("Second"); await session.setLabel("user", "keep"); await session.setLabel("user", undefined); + await rejectsWithCode(session.setLabel("missing", "checkpoint"), "not_found"); strictEqual(await session.getName(), "Second"); strictEqual(await session.getLabel("user"), undefined); diff --git a/packages/agent/src/harness/experimental/session/testing/index.ts b/packages/agent/src/harness/session/testing/index.ts similarity index 100% rename from packages/agent/src/harness/experimental/session/testing/index.ts rename to packages/agent/src/harness/session/testing/index.ts diff --git a/packages/agent/src/harness/experimental/session/testing/types.ts b/packages/agent/src/harness/session/testing/types.ts similarity index 100% rename from packages/agent/src/harness/experimental/session/testing/types.ts rename to packages/agent/src/harness/session/testing/types.ts diff --git a/packages/agent/src/harness/experimental/session/types.ts b/packages/agent/src/harness/session/types.ts similarity index 97% rename from packages/agent/src/harness/experimental/session/types.ts rename to packages/agent/src/harness/session/types.ts index 017cd7fff30..20d34681822 100644 --- a/packages/agent/src/harness/experimental/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -1,6 +1,6 @@ import type { StopReason, Usage } from "@cheetahbyte/pi-ai"; -import "../../messages.ts"; -import type { AgentMessage } from "../../../types.ts"; +import "../messages.ts"; +import type { AgentMessage } from "../../types.ts"; import type { Session } from "./session.ts"; export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -31,12 +31,12 @@ export interface ModelChangeEntry extends EntryBase { modelId: string; } -export interface ThinkingLevelChangeEntry extends EntryBase { +export interface ThinkingLevelEntry extends EntryBase { type: "thinking_level_change"; thinkingLevel: string; } -export interface ActiveToolsChangeEntry extends EntryBase { +export interface ActiveToolsEntry extends EntryBase { type: "active_tools_change"; activeToolNames: string[]; } @@ -67,8 +67,8 @@ export interface CustomEntry extends EntryBase { export type Entry = | MessageEntry | ModelChangeEntry - | ThinkingLevelChangeEntry - | ActiveToolsChangeEntry + | ThinkingLevelEntry + | ActiveToolsEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; @@ -115,7 +115,6 @@ export interface OperationStartedRecord extends RecordBase { export interface AbortRequestedRecord extends RecordBase { type: "abort_requested"; runId: string; - reason: "user" | "shutdown"; } export interface OperationFinishedRecord extends RecordBase { diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index f46db91bd45..8198309e7b3 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -1,24 +1,6 @@ -import type { - ImageContent, - Model, - Models, - RetryPolicy, - SimpleStreamOptions, - TextContent, - Transport, - Usage, -} from "@cheetahbyte/pi-ai"; +import type { SimpleStreamOptions, Transport } from "@cheetahbyte/pi-ai"; import type { Static, TSchema } from "typebox"; -import type { - AgentEvent, - AgentMessage, - AgentTool, - AgentToolResult, - AgentToolUpdateCallback, - QueueMode, - ThinkingLevel, -} from "../index.ts"; -import type { Session } from "./session/session.ts"; +import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "../types.ts"; /** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; @@ -194,7 +176,7 @@ export class ExecutionError extends Error { } /** Stable compaction error codes returned by compaction helpers. */ -export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown"; +export type CompactionErrorCode = "aborted" | "summarization_failed"; /** Error returned by compaction helpers. */ export class CompactionError extends Error { @@ -209,7 +191,7 @@ export class CompactionError extends Error { } /** Stable branch-summary error codes returned by branch summarization helpers. */ -export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; +export type BranchSummaryErrorCode = "aborted" | "summarization_failed"; /** Error returned by branch summarization helpers. */ export class BranchSummaryError extends Error { @@ -223,48 +205,6 @@ export class BranchSummaryError extends Error { } } -export type SessionErrorCode = - | "not_found" - | "invalid_session" - | "invalid_entry" - | "invalid_fork_target" - | "storage" - | "unknown"; - -/** Error thrown by session storage, repositories, and session tree operations. */ -export class SessionError extends Error { - /** Session subsystem error code. */ - public code: SessionErrorCode; - - constructor(code: SessionErrorCode, message: string, cause?: Error) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "SessionError"; - this.code = code; - } -} - -export type AgentHarnessErrorCode = - | "busy" - | "invalid_state" - | "invalid_argument" - | "session" - | "hook" - | "auth" - | "compaction" - | "branch_summary" - | "unknown"; - -/** Public AgentHarness failure with a stable top-level classification. */ -export class AgentHarnessError extends Error { - public code: AgentHarnessErrorCode; - - constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "AgentHarnessError"; - this.code = code; - } -} - /** Metadata for one filesystem object in a {@link FileSystem}. */ export interface FileInfo { /** Basename of {@link path}. */ @@ -371,618 +311,3 @@ export interface Shell { /** Filesystem and process execution environment used by the harness. */ export interface ExecutionEnv extends FileSystem, Shell {} - -export interface SessionTreeEntryBase { - type: string; - id: string; - parentId: string | null; - timestamp: string; -} - -export interface MessageEntry extends SessionTreeEntryBase { - type: "message"; - message: AgentMessage; -} - -export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { - type: "thinking_level_change"; - thinkingLevel: string; -} - -export interface ModelChangeEntry extends SessionTreeEntryBase { - type: "model_change"; - provider: string; - modelId: string; -} - -export interface ActiveToolsChangeEntry extends SessionTreeEntryBase { - type: "active_tools_change"; - activeToolNames: string[]; -} - -export interface CompactionEntry extends SessionTreeEntryBase { - type: "compaction"; - summary: string; - firstKeptEntryId?: string; - tokensBefore: number; - retainedTail?: AgentMessage[]; - details?: T; - usage?: Usage; - fromHook?: boolean; -} - -export interface BranchSummaryEntry extends SessionTreeEntryBase { - type: "branch_summary"; - fromId: string; - summary: string; - details?: T; - usage?: Usage; - fromHook?: boolean; -} - -export interface CustomEntry extends SessionTreeEntryBase { - type: "custom"; - customType: string; - data?: T; -} - -export interface CustomMessageEntry extends SessionTreeEntryBase { - type: "custom_message"; - customType: string; - content: string | (TextContent | ImageContent)[]; - details?: T; - display: boolean; -} - -export interface LabelEntry extends SessionTreeEntryBase { - type: "label"; - targetId: string; - label: string | undefined; -} - -export interface SessionInfoEntry extends SessionTreeEntryBase { - type: "session_info"; // legacy name, kept for backwards compatibility - name?: string; -} - -export interface LeafEntry extends SessionTreeEntryBase { - type: "leaf"; - targetId: string | null; -} - -export type SessionTreeEntry = - | MessageEntry - | ThinkingLevelChangeEntry - | ModelChangeEntry - | ActiveToolsChangeEntry - | CompactionEntry - | BranchSummaryEntry - | CustomEntry - | CustomMessageEntry - | LabelEntry - | SessionInfoEntry - | LeafEntry; - -export interface SessionContext { - messages: AgentMessage[]; - thinkingLevel: string; - model: { provider: string; modelId: string } | null; - activeToolNames: string[] | null; -} - -export interface SessionStats { - messageCount: number; - cachedTokens: number; - uncachedTokens: number; - totalTokens: number; - costTotal: number; -} - -export interface SessionMetadata { - id: string; - createdAt: string; -} - -export interface JsonlSessionMetadata extends SessionMetadata { - cwd: string; - path: string; - parentSessionPath?: string; - metadata?: Record; -} - -export interface SessionEntryCursorOptions { - /** Number of entries already consumed; reading starts at this zero-based sequence. */ - afterEntrySeq?: number; - limit?: number; -} - -export type { Session } from "./session/session.ts"; - -export interface SessionCreateOptions { - id?: string; -} - -export interface SessionSearchOptions { - text: string; - cwd?: string; -} - -export interface SessionSearchHit { - metadata: TMetadata; - entryId: string; - timestamp: string; - snippet?: string; - score?: number; -} - -/** Owns session search queries. */ -export interface SessionSearch { - search(options: SessionSearchOptions): Promise[]>; -} - -export interface SessionForkOptions { - entryId?: string; - position?: "before" | "at"; - id?: string; -} - -export type SessionForkSelection = - /** Copy all persisted entries in append order. */ - | { kind: "all" } - /** Copy the target's active path, excluding the target; the target must be a user message. */ - | { kind: "before_user_message"; entryId: string } - /** Copy the target's active path, including the target. */ - | { kind: "through_entry"; entryId: string }; - -export interface SessionBranchQuery { - /** Entry where traversal starts. Session defaults this to its active leaf. */ - start?: string | null; - /** Stop after the first matching entry, inclusive. */ - stopAtType?: SessionTreeEntry["type"]; - /** Stop after the matching entry, inclusive. */ - stopAtId?: string; - /** Filter returned entries by type after determining traversal bounds. */ - type?: SessionTreeEntry["type"]; - /** Filter returned custom entries by custom type. */ - customType?: string; - /** Traversal order. Defaults to newest first. */ - order?: "newestFirst" | "oldestFirst"; - /** Maximum number of filtered entries to return. */ - limit?: number; -} - -export interface SessionHead { - leafId: string | null; -} - -/** Complete storage contract for one opened session. Its lifetime is owned by its repository. */ -export interface SessionStorage { - readonly metadata: TMetadata; - /** Rejects with `invalid_session` when a non-null active leaf does not reference a stored entry. */ - readHead(): Promise; - readEntry(id: string): Promise; - readEntries(options?: SessionEntryCursorOptions): Promise; - appendEntry(entry: SessionTreeEntry): Promise; - findEntriesOnBranch(query: SessionBranchQuery & { start: string | null }): Promise; - readPathToRootOrCompaction(leafId: string | null): Promise; - getLabel(id: string): Promise; - getName(): Promise; - getStats(): Promise; -} - -export interface JsonlSessionCreateOptions extends SessionCreateOptions { - cwd: string; - parentSessionPath?: string; - metadata?: Record; -} - -export interface JsonlSessionListOptions { - cwd?: string; -} - -export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; - -export type PendingSessionWrite = SessionTreeEntry extends infer TEntry - ? TEntry extends SessionTreeEntry - ? Omit - : never - : never; - -export interface QueueUpdateEvent { - type: "queue_update"; - steer: AgentMessage[]; - followUp: AgentMessage[]; - nextTurn: AgentMessage[]; -} - -export interface SavePointEvent { - type: "save_point"; - hadPendingMutations: boolean; -} - -export interface AbortEvent { - type: "abort"; - clearedSteer: AgentMessage[]; - clearedFollowUp: AgentMessage[]; -} - -export interface SettledEvent { - type: "settled"; - nextTurnCount: number; -} - -export interface BeforeAgentStartEvent< - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, -> { - type: "before_agent_start"; - prompt: string; - images?: ImageContent[]; - systemPrompt: string; - resources: AgentHarnessResources; -} - -export interface ContextEvent { - type: "context"; - messages: AgentMessage[]; -} - -export interface BeforeProviderRequestEvent { - type: "before_provider_request"; - model: Model; - sessionId: string; - streamOptions: AgentHarnessStreamOptions; -} - -export interface BeforeProviderPayloadEvent { - type: "before_provider_payload"; - model: Model; - payload: unknown; -} - -export interface AfterProviderResponseEvent { - type: "after_provider_response"; - status: number; - headers: Record; -} - -export interface ToolCallEvent { - type: "tool_call"; - toolCallId: string; - toolName: string; - input: Record; -} - -export interface ToolResultEvent { - type: "tool_result"; - toolCallId: string; - toolName: string; - input: Record; - content: Array; - details: unknown; - isError: boolean; - usage?: Usage; -} - -export interface SessionBeforeCompactEvent { - type: "session_before_compact"; - preparation: CompactionPreparation; - branchEntries: SessionTreeEntry[]; - customInstructions?: string; - signal: AbortSignal; -} - -export interface SessionCompactEvent { - type: "session_compact"; - compactionEntry: CompactionEntry; - fromHook: boolean; -} - -export interface SessionBeforeTreeEvent { - type: "session_before_tree"; - preparation: TreePreparation; - signal: AbortSignal; -} - -export interface SessionTreeEvent { - type: "session_tree"; - newLeafId: string | null; - oldLeafId: string | null; - summaryEntry?: BranchSummaryEntry; - fromHook?: boolean; -} - -export interface RetryScheduledEvent { - type: "retry_scheduled"; - operation: "compaction" | "branch_summary"; - attempt: number; - maxAttempts: number; - delayMs: number; - errorMessage: string; -} - -export interface RetryAttemptStartEvent { - type: "retry_attempt_start"; - operation: "compaction" | "branch_summary"; -} - -export interface RetryFinishedEvent { - type: "retry_finished"; - operation: "compaction" | "branch_summary"; -} - -export interface ModelUpdateEvent { - type: "model_update"; - model: Model; - previousModel: Model | undefined; - source: "set" | "restore"; -} - -export interface ThinkingLevelUpdateEvent { - type: "thinking_level_update"; - level: ThinkingLevel; - previousLevel: ThinkingLevel; -} - -export interface ToolsUpdateEvent { - type: "tools_update"; - toolNames: string[]; - previousToolNames: string[]; - activeToolNames: string[]; - previousActiveToolNames: string[]; - source: "set" | "restore"; -} - -export interface ResourcesUpdateEvent< - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, -> { - type: "resources_update"; - resources: AgentHarnessResources; - previousResources: AgentHarnessResources; -} - -export type AgentHarnessOwnEvent< - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, -> = - | QueueUpdateEvent - | SavePointEvent - | AbortEvent - | SettledEvent - | BeforeAgentStartEvent - | ContextEvent - | BeforeProviderRequestEvent - | BeforeProviderPayloadEvent - | AfterProviderResponseEvent - | ToolCallEvent - | ToolResultEvent - | SessionBeforeCompactEvent - | SessionCompactEvent - | SessionBeforeTreeEvent - | SessionTreeEvent - | RetryScheduledEvent - | RetryAttemptStartEvent - | RetryFinishedEvent - | ModelUpdateEvent - | ThinkingLevelUpdateEvent - | ResourcesUpdateEvent - | ToolsUpdateEvent; - -export type AgentHarnessEvent = - | AgentEvent - | AgentHarnessOwnEvent; - -export interface BeforeAgentStartResult { - messages?: AgentMessage[]; - systemPrompt?: string; -} - -export interface ContextResult { - messages: AgentMessage[]; -} - -export interface BeforeProviderRequestResult { - streamOptions?: AgentHarnessStreamOptionsPatch; -} - -export interface BeforeProviderPayloadResult { - payload: unknown; -} - -export interface ToolCallResult { - block?: boolean; - reason?: string; -} - -export interface ToolResultPatch { - content?: Array; - details?: unknown; - isError?: boolean; - usage?: Usage; - terminate?: boolean; -} - -export interface SessionBeforeCompactResult { - cancel?: boolean; - compaction?: CompactResult; -} - -export interface SessionBeforeTreeResult { - cancel?: boolean; - summary?: { - summary: string; - details?: unknown; - /** Usage from the LLM call that generated this summary, if available. */ - usage?: Usage; - }; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; -} - -export type AgentHarnessEventResultMap = { - before_agent_start: BeforeAgentStartResult | undefined; - context: ContextResult | undefined; - before_provider_request: BeforeProviderRequestResult | undefined; - before_provider_payload: BeforeProviderPayloadResult | undefined; - after_provider_response: undefined; - tool_call: ToolCallResult | undefined; - tool_result: ToolResultPatch | undefined; - session_before_compact: SessionBeforeCompactResult | undefined; - session_compact: undefined; - session_before_tree: SessionBeforeTreeResult | undefined; - session_tree: undefined; - retry_scheduled: undefined; - retry_attempt_start: undefined; - retry_finished: undefined; - model_update: undefined; - thinking_level_update: undefined; - resources_update: undefined; - tools_update: undefined; - queue_update: undefined; - save_point: undefined; - abort: undefined; - settled: undefined; -}; - -export interface AgentHarnessPromptOptions { - images?: ImageContent[]; -} - -export interface AbortResult { - clearedSteer: AgentMessage[]; - clearedFollowUp: AgentMessage[]; -} - -export interface CompactResult { - summary: string; - firstKeptEntryId?: string; - tokensBefore: number; - /** Usage from the LLM call(s) that generated this summary, if available. */ - usage?: Usage; - retainedTail?: AgentMessage[]; - details?: unknown; -} - -export interface NavigateTreeResult { - cancelled: boolean; - editorText?: string; - summaryEntry?: BranchSummaryEntry; -} - -export interface CompactionSettings { - enabled: boolean; - reserveTokens: number; - keepRecentTokens: number; -} - -export interface CompactionPreparation { - firstKeptEntryId: string; - messagesToSummarize: AgentMessage[]; - turnPrefixMessages: AgentMessage[]; - retainedTail: AgentMessage[]; - isSplitTurn: boolean; - tokensBefore: number; - previousSummary?: string; - fileOps: FileOperations; - settings: CompactionSettings; -} - -export interface FileOperations { - read: Set; - written: Set; - edited: Set; -} - -export interface TreePreparation { - targetId: string; - oldLeafId: string | null; - commonAncestorId: string | null; - entriesToSummarize: SessionTreeEntry[]; - userWantsSummary: boolean; - customInstructions?: string; - replaceInstructions?: boolean; - label?: string; -} - -export interface GenerateBranchSummaryOptions { - model: Model; - apiKey: string; - headers?: Record; - signal: AbortSignal; - customInstructions?: string; - replaceInstructions?: boolean; - reserveTokens?: number; -} - -export interface BranchSummaryResult { - summary: string; - usage?: Usage; - readFiles: string[]; - modifiedFiles: string[]; -} - -export type AgentHarnessSystemPrompt< - TContext extends object | undefined = undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> = - | string - | ((context: { - session: Session; - model: Model; - thinkingLevel: ThinkingLevel; - activeTools: TTool[]; - resources: AgentHarnessResources; - }) => string | Promise); - -interface AgentHarnessOptionsBase< - TContext extends object | undefined, - TSkill extends Skill, - TPromptTemplate extends PromptTemplate, - TTool extends AgentHarnessTool, -> { - session: Session; - /** - * Provider collection used for all model requests (turn streaming, - * compaction, branch summarization). Auth resolves through the providers' - * auth. - */ - models: Models; - tools?: TTool[]; - /** - * Concrete resources available to explicit invocation methods and system-prompt callbacks. - * Applications own loading/reloading resources and should call `setResources()` with new values. - */ - resources?: AgentHarnessResources; - systemPrompt?: AgentHarnessSystemPrompt; - /** Curated stream/provider request options. Snapshotted at turn start. */ - streamOptions?: AgentHarnessStreamOptions; - /** Optional retry policy for generated compaction and branch-summary requests. */ - retry?: RetryPolicy; - model: Model; - thinkingLevel?: ThinkingLevel; - activeToolNames?: string[]; - steeringMode?: QueueMode; - followUpMode?: QueueMode; -} - -export type AgentHarnessOptions< - TContext extends object | undefined = undefined, - TSkill extends Skill = Skill, - TPromptTemplate extends PromptTemplate = PromptTemplate, - TTool extends AgentHarnessTool = AgentHarnessTool, -> = AgentHarnessOptionsBase & - ([TContext] extends [undefined] - ? { - /** Context-free harnesses do not need a tool context. */ - toolContext?: undefined; - } - : { - /** Static context or zero-argument context provider resolved for each turn snapshot. */ - toolContext: AgentHarnessToolContextSource; - }); - -export type { AgentHarness } from "./agent-harness.ts"; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 20891f064d5..5b385dbfcf3 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -7,12 +7,18 @@ export * from "./harness/agent-harness.ts"; export { type BranchPreparation, type BranchSummaryDetails, + type BranchSummaryResult, type CollectEntriesResult, collectEntriesForBranchSummary, + type FileOperations, + type GenerateBranchSummaryOptions, generateBranchSummary, prepareBranchEntries, } from "./harness/compaction/branch-summarization.ts"; export { + type CompactionPreparation, + type CompactionSettings, + type CompactResult, calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, @@ -29,34 +35,41 @@ export { } from "./harness/compaction/compaction.ts"; export * from "./harness/messages.ts"; export * from "./harness/prompt-templates.ts"; -export { - JsonlSessionRepository, - type JsonlSessionRepositoryFileSystem, - type JsonlSessionRepositoryOptions, - loadJsonlSessionMetadata, -} from "./harness/session/jsonl-repo.ts"; -export { - type InMemorySessionCreateOptions, - InMemorySessionRepository, - type InMemorySessionRepositoryOptions, -} from "./harness/session/memory-repo.ts"; -export * from "./harness/session/repository.ts"; +// Harness +export * from "./harness/result.ts"; +export * from "./harness/session/index.ts"; export * from "./harness/session/search.ts"; -export { - buildContextEntries, - buildSessionContext, - type ContextEntryTransform, - type CustomEntryContextMessageProjector, - createSession, - defaultContextEntryTransform, - type SessionContextBuildOptions, - sessionEntryToContextMessages, -} from "./harness/session/session.ts"; export * from "./harness/skills.ts"; export * from "./harness/system-prompt.ts"; export * from "./harness/tools/index.ts"; -// Harness -export * from "./harness/types.ts"; +export { + type AgentHarnessResources, + type AgentHarnessStreamOptions, + type AgentHarnessStreamOptionsPatch, + type AgentHarnessTool, + type AgentHarnessToolContextSource, + BranchSummaryError, + type BranchSummaryErrorCode, + CompactionError, + type CompactionErrorCode, + type ExecutionEnv, + ExecutionError, + type ExecutionErrorCode, + err, + FileError, + type FileErrorCode, + type FileInfo, + type FileKind, + type FileSystem, + getOrThrow, + getOrUndefined, + ok, + type PromptTemplate, + type Shell, + type ShellExecOptions, + type Skill, + toError, +} from "./harness/types.ts"; export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/truncate.ts"; // Proxy utilities diff --git a/packages/agent/test/harness/agent-harness-scaffold.test.ts b/packages/agent/test/harness/agent-harness-scaffold.test.ts new file mode 100644 index 00000000000..5253de5a16b --- /dev/null +++ b/packages/agent/test/harness/agent-harness-scaffold.test.ts @@ -0,0 +1,34 @@ +import { createModels } from "@cheetahbyte/pi-ai"; +import { getModel } from "@cheetahbyte/pi-ai/compat"; +import { describe, expect, it } from "vitest"; +import { AgentHarness, HarnessClosed, HarnessNotImplemented } from "../../src/harness/agent-harness.ts"; +import { InMemorySessionStorage, Session } from "../../src/harness/session/index.ts"; + +describe("AgentHarness v2 scaffold", () => { + it("opens the main lane over a v4 session", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 })); + const { harness, suspended } = await AgentHarness.create({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + }); + + expect(suspended).toEqual([]); + expect(harness.name).toBe("main"); + expect(await harness.getLeafId()).toBeNull(); + expect(await harness.lanes()).toEqual([{ name: "main", leafId: null, operation: null }]); + }); + + it("rejects unimplemented operations explicitly", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 })); + const { harness } = await AgentHarness.create({ + session, + models: createModels(), + model: getModel("google", "gemini-2.5-flash"), + }); + + await expect(harness.prompt("hello")).rejects.toBeInstanceOf(HarnessNotImplemented); + await harness.close(); + await expect(harness.prompt("hello")).rejects.toBeInstanceOf(HarnessClosed); + }); +}); diff --git a/packages/agent/test/harness/agent-harness-stream.test.ts b/packages/agent/test/harness/agent-harness-stream.test.ts deleted file mode 100644 index f32dbcb0a36..00000000000 --- a/packages/agent/test/harness/agent-harness-stream.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - createModels, - type FauxProviderHandle, - fauxAssistantMessage, - fauxProvider, - fauxToolCall, - type StreamOptions, -} from "@cheetahbyte/pi-ai"; -import { describe, expect, it } from "vitest"; -import { AgentHarness } from "../../src/harness/agent-harness.ts"; -import type { AgentHarnessOptions } from "../../src/harness/types.ts"; -import { calculateTool } from "../utils/calculate.ts"; -import { createInMemorySession } from "./session-test-utils.ts"; - -/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */ -const models = createModels(); -let fauxCount = 0; - -function newFaux(): FauxProviderHandle { - const faux = fauxProvider({ provider: `faux-${++fauxCount}` }); - models.setProvider(faux.provider); - return faux; -} - -function createHarness(options: AgentHarnessOptions): AgentHarness { - return new AgentHarness(options); -} - -function captureOptions(options: StreamOptions | undefined): StreamOptions { - return { - ...options, - headers: options?.headers ? { ...options.headers } : undefined, - metadata: options?.metadata ? { ...options.metadata } : undefined, - }; -} - -describe("AgentHarness stream configuration", () => { - it("snapshots stream options before provider request hooks", async () => { - let capturedOptions: StreamOptions | undefined; - const registration = newFaux(); - registration.setResponses([ - (_context, options) => { - capturedOptions = options; - return fauxAssistantMessage("ok"); - }, - ]); - - const session = await createInMemorySession("session-1"); - const harness = createHarness({ - models, - session, - model: registration.getModel(), - streamOptions: { - timeoutMs: 1000, - maxRetries: 2, - maxRetryDelayMs: 3000, - headers: { "x-base": "base" }, - metadata: { base: true }, - cacheRetention: "none", - }, - }); - - harness.on("before_provider_request", (event) => { - expect(event.sessionId).toBe("session-1"); - expect(event.streamOptions.headers).toEqual({ "x-base": "base" }); - return { - streamOptions: { - headers: { "x-hook": "hook" }, - metadata: { hook: true }, - }, - }; - }); - - await harness.prompt("hello"); - - expect(capturedOptions).toMatchObject({ - timeoutMs: 1000, - maxRetries: 2, - maxRetryDelayMs: 3000, - sessionId: "session-1", - cacheRetention: "none", - }); - expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-hook": "hook" }); - expect(capturedOptions?.metadata).toEqual({ base: true, hook: true }); - }); - - it("chains provider request patches and supports deletion semantics", async () => { - let capturedOptions: StreamOptions | undefined; - const registration = newFaux(); - registration.setResponses([ - (_context, options) => { - capturedOptions = options; - return fauxAssistantMessage("ok"); - }, - ]); - - const harness = createHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - streamOptions: { - timeoutMs: 1000, - maxRetries: 2, - headers: { keep: "base", remove: "base" }, - metadata: { keep: "base", remove: "base" }, - }, - }); - - harness.on("before_provider_request", (event) => { - expect(event.streamOptions.headers).toEqual({ keep: "base", remove: "base" }); - return { - streamOptions: { - headers: { first: "1", remove: undefined }, - metadata: { first: 1, remove: undefined }, - }, - }; - }); - harness.on("before_provider_request", (event) => { - expect(event.streamOptions.headers).toEqual({ keep: "base", first: "1" }); - expect(event.streamOptions.metadata).toEqual({ keep: "base", first: 1 }); - return { - streamOptions: { - timeoutMs: undefined, - headers: { second: "2" }, - metadata: undefined, - }, - }; - }); - - await harness.prompt("hello"); - - expect(capturedOptions?.timeoutMs).toBeUndefined(); - expect(capturedOptions?.maxRetries).toBe(2); - expect(capturedOptions?.headers).toEqual({ keep: "base", first: "1", second: "2" }); - expect(capturedOptions?.metadata).toBeUndefined(); - }); - - it("uses updated stream options for save-point snapshots without mutating the active request", async () => { - const capturedOptions: StreamOptions[] = []; - const registration = newFaux(); - registration.setResponses([ - (_context, options) => { - capturedOptions.push(captureOptions(options)); - return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), { - stopReason: "toolUse", - }); - }, - (_context, options) => { - capturedOptions.push(captureOptions(options)); - return fauxAssistantMessage("done"); - }, - ]); - - const harness = createHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - tools: [calculateTool], - streamOptions: { timeoutMs: 1000, headers: { turn: "first" } }, - }); - - harness.subscribe((event) => { - if (event.type === "tool_execution_start") { - harness.setStreamOptions({ timeoutMs: 2000, headers: { turn: "second" } }); - } - }); - - await harness.prompt("hello"); - - expect(capturedOptions).toHaveLength(2); - expect(capturedOptions[0].timeoutMs).toBe(1000); - expect(capturedOptions[0].headers).toEqual({ turn: "first" }); - expect(capturedOptions[1].timeoutMs).toBe(2000); - expect(capturedOptions[1].headers).toEqual({ turn: "second" }); - }); - - it("chains provider payload hooks", async () => { - const seenPayloads: unknown[] = []; - let finalPayload: unknown; - const registration = newFaux(); - registration.setResponses([ - async (_context, options, _state, model) => { - finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model); - return fauxAssistantMessage("ok"); - }, - ]); - - const harness = createHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - - harness.on("before_provider_payload", (event) => { - seenPayloads.push(event.payload); - return { payload: { steps: ["provider", "first"] } }; - }); - harness.on("before_provider_payload", (event) => { - seenPayloads.push(event.payload); - return { payload: { steps: ["provider", "first", "second"] } }; - }); - - await harness.prompt("hello"); - - expect(seenPayloads).toEqual([{ steps: ["provider"] }, { steps: ["provider", "first"] }]); - expect(finalPayload).toEqual({ steps: ["provider", "first", "second"] }); - }); -}); diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts deleted file mode 100644 index b8007647428..00000000000 --- a/packages/agent/test/harness/agent-harness.test.ts +++ /dev/null @@ -1,1280 +0,0 @@ -import { - createModels, - type FauxProviderHandle, - fauxAssistantMessage, - fauxProvider, - fauxToolCall, - type RegisterFauxProviderOptions, - type Usage, -} from "@cheetahbyte/pi-ai"; -import { getModel } from "@cheetahbyte/pi-ai/compat"; -import { describe, expect, it } from "vitest"; -import { AgentHarness } from "../../src/harness/agent-harness.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { InMemorySessionBackend } from "../../src/harness/session/memory-repo.ts"; -import { createSession, type Session } from "../../src/harness/session/session.ts"; -import type { AgentHarnessTool, PromptTemplate, Skill } from "../../src/harness/types.ts"; -import type { AgentMessage, AgentTool } from "../../src/types.ts"; -import { calculateTool, createCalculateToolWithUsage } from "../utils/calculate.ts"; -import { getCurrentTimeTool } from "../utils/get-current-time.ts"; -import { createInMemorySession } from "./session-test-utils.ts"; - -interface AppSkill extends Skill { - source: "project" | "user"; -} - -interface AppPromptTemplate extends PromptTemplate { - source: "project" | "user"; -} - -/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */ -const models = createModels(); -let fauxCount = 0; - -function newFaux(options: RegisterFauxProviderOptions = {}): FauxProviderHandle { - const faux = fauxProvider({ provider: `faux-${++fauxCount}`, ...options }); - models.setProvider(faux.provider); - return faux; -} - -function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] { - return messages.flatMap((message) => { - if (message.role !== "user") return []; - if (typeof message.content === "string") return [message.content]; - if (!Array.isArray(message.content)) return []; - return message.content.flatMap((part) => { - if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return []; - return "text" in part && typeof part.text === "string" ? [part.text] : []; - }); - }); -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve = () => {}; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -function getReasoning(options: unknown): unknown { - if (!options || typeof options !== "object" || !("reasoning" in options)) return undefined; - return options.reasoning; -} - -function createUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage { - return { - input, - output, - cacheRead, - cacheWrite, - totalTokens: input + output + cacheRead + cacheWrite, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; -} - -function createUserMessage(text: string): AgentMessage { - return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() }; -} - -function createAssistantMessage(text: string): AgentMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "faux", - provider: "faux", - model: "faux-1", - usage: createUsage(100, 50), - stopReason: "stop", - timestamp: Date.now(), - }; -} - -async function createBlockingSession(expectedWrites: number): Promise<{ - session: Session; - allWritesStarted: ReturnType; - releaseWrites: ReturnType; - getEntries(): ReturnType; -}> { - const source = new InMemorySessionBackend(); - const allWritesStarted = deferred(); - const releaseWrites = deferred(); - let writesStarted = 0; - const blockWrites = async (storage: Awaited>) => ({ - ...storage, - async appendEntry(entry: Parameters[0]) { - writesStarted++; - if (writesStarted === expectedWrites) allWritesStarted.resolve(); - await releaseWrites.promise; - await storage.appendEntry(entry); - }, - }); - const blockingBackend: Pick< - InMemorySessionBackend, - "create" | "open" | "list" | "delete" | "fork" | typeof Symbol.asyncDispose - > = { - create: async (options) => blockWrites(await source.create(options)), - open: async (metadata) => blockWrites(await source.open(metadata)), - list: () => source.list(), - delete: (metadata) => source.delete(metadata), - fork: async (metadata, options, selection) => blockWrites(await source.fork(metadata, options, selection)), - [Symbol.asyncDispose]: () => source[Symbol.asyncDispose](), - }; - const session = await createSession(await blockingBackend.create({})); - return { - session, - allWritesStarted, - releaseWrites, - getEntries: () => session.getEntries(), - }; -} - -describe("AgentHarness", () => { - it("constructs directly and exposes queue modes", async () => { - const session = await createInMemorySession(); - const initialModel = getModel("anthropic", "claude-sonnet-4-5"); - const harness = new AgentHarness({ - models, - session, - model: initialModel, - thinkingLevel: "high", - systemPrompt: "You are helpful.", - steeringMode: "all", - followUpMode: "all", - }); - expect(harness.getModel()).toBe(initialModel); - expect(harness.getThinkingLevel()).toBe("high"); - expect(harness.getSteeringMode()).toBe("all"); - expect(harness.getFollowUpMode()).toBe("all"); - harness.setSteeringMode("one-at-a-time"); - harness.setFollowUpMode("one-at-a-time"); - expect(harness.getSteeringMode()).toBe("one-at-a-time"); - expect(harness.getFollowUpMode()).toBe("one-at-a-time"); - }); - - it("rejects waiting before shutdown is requested", async () => { - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - - await expect(harness.waitForShutdown()).rejects.toMatchObject({ - code: "invalid_state", - message: "Shutdown has not been requested", - }); - }); - - it("shuts down active work permanently and idempotently", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let signal: AbortSignal | undefined; - registration.setResponses([ - async (_context, options) => { - signal = options?.signal; - entered.resolve(); - await release.promise; - return fauxAssistantMessage("finished after shutdown"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - const prompt = harness.prompt("hello"); - await entered.promise; - await harness.steer("queued steer"); - await harness.followUp("queued follow-up"); - await harness.nextTurn("queued next turn"); - - let firstShutdownSettled = false; - harness.requestShutdown(); - const firstShutdown = harness.waitForShutdown().then(() => { - firstShutdownSettled = true; - }); - harness.requestShutdown(); - const secondShutdown = harness.waitForShutdown(); - await Promise.resolve(); - - expect(signal?.aborted).toBe(true); - expect(firstShutdownSettled).toBe(false); - release.resolve(); - await expect(prompt).resolves.toMatchObject({ role: "assistant" }); - await expect(Promise.all([firstShutdown, secondShutdown])).resolves.toEqual([undefined, undefined]); - await expect(harness.prompt("again")).rejects.toMatchObject({ - code: "invalid_state", - message: "AgentHarness has been shut down", - }); - await expect(harness.nextTurn("again")).rejects.toMatchObject({ code: "invalid_state" }); - await expect(harness.appendMessage(createUserMessage("again"))).rejects.toMatchObject({ - code: "invalid_state", - }); - }); - - it("allows a hook to request shutdown without deadlocking its operation", async () => { - const registration = newFaux(); - let providerCalls = 0; - registration.setResponses([ - () => { - providerCalls++; - return fauxAssistantMessage("must not run"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - harness.on("before_agent_start", () => { - harness.requestShutdown(); - return undefined; - }); - - await expect(harness.prompt("hello")).rejects.toMatchObject({ code: "invalid_state" }); - await expect(harness.waitForShutdown()).resolves.toBeUndefined(); - expect(providerCalls).toBe(0); - }); - - it("allows a subscriber to request shutdown without deadlocking its operation", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("reply")]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - let subscriberCalls = 0; - harness.subscribe((event) => { - subscriberCalls++; - if (event.type === "message_start" && event.message.role === "assistant") { - harness.requestShutdown(); - } - }); - - await expect(harness.prompt("hello")).resolves.toMatchObject({ role: "assistant", stopReason: "aborted" }); - await expect(harness.waitForShutdown()).resolves.toBeUndefined(); - expect(subscriberCalls).toBeGreaterThan(1); - }); - - it("does not start a provider request when shutdown occurs during before_agent_start", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let providerCalls = 0; - registration.setResponses([ - () => { - providerCalls++; - return fauxAssistantMessage("must not run"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - harness.on("before_agent_start", async () => { - entered.resolve(); - await release.promise; - return undefined; - }); - const prompt = harness.prompt("hello"); - await entered.promise; - - let shutdownSettled = false; - harness.requestShutdown(); - const shutdown = harness.waitForShutdown().then(() => { - shutdownSettled = true; - }); - await Promise.resolve(); - - expect(shutdownSettled).toBe(false); - release.resolve(); - await expect(prompt).rejects.toMatchObject({ code: "invalid_state" }); - await shutdown; - expect(providerCalls).toBe(0); - }); - - it("aborts and awaits active compaction without persisting its result", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let signal: AbortSignal | undefined; - registration.setResponses([ - async (_context, options) => { - signal = options?.signal; - entered.resolve(); - await release.promise; - return fauxAssistantMessage("summary produced after shutdown"); - }, - ]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ models, session, model: registration.getModel() }); - const compaction = harness.compact(); - await entered.promise; - - let shutdownSettled = false; - harness.requestShutdown(); - const shutdown = harness.waitForShutdown().then(() => { - shutdownSettled = true; - }); - await Promise.resolve(); - - expect(signal?.aborted).toBe(true); - expect(shutdownSettled).toBe(false); - release.resolve(); - await expect(compaction).rejects.toMatchObject({ code: "compaction" }); - await shutdown; - expect((await session.getEntries()).some((entry) => entry.type === "compaction")).toBe(false); - }); - - it("aborts and awaits active tree navigation without moving the session leaf", async () => { - const registration = newFaux(); - const entered = deferred(); - const release = deferred(); - let signal: AbortSignal | undefined; - registration.setResponses([ - async (_context, options) => { - signal = options?.signal; - entered.resolve(); - await release.promise; - return fauxAssistantMessage("summary produced after shutdown"); - }, - ]); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - const originalLeafId = await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ models, session, model: registration.getModel() }); - const navigation = harness.navigateTree(targetId, { summarize: true }); - await entered.promise; - - let shutdownSettled = false; - harness.requestShutdown(); - const shutdown = harness.waitForShutdown().then(() => { - shutdownSettled = true; - }); - await Promise.resolve(); - - expect(signal?.aborted).toBe(true); - expect(shutdownSettled).toBe(false); - release.resolve(); - await expect(navigation).resolves.toEqual({ cancelled: true }); - await shutdown; - expect(await session.getLeafId()).toBe(originalLeafId); - }); - - it("does not treat concurrent mutations as active operations", async () => { - const blocking = await createBlockingSession(1); - const harness = new AgentHarness({ - models, - session: blocking.session, - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - const mutation = harness.appendMessage(createUserMessage("concurrent")); - await blocking.allWritesStarted.promise; - - const firstSettlement = await Promise.race([ - harness.waitForIdle().then(() => "idle" as const), - new Promise<"mutation-pending">((resolve) => setImmediate(() => resolve("mutation-pending"))), - ]); - - expect(firstSettlement).toBe("idle"); - blocking.releaseWrites.resolve(); - await mutation; - }); - - it("awaits concurrent idle session mutations before shutdown resolves", async () => { - const blocking = await createBlockingSession(1); - const harness = new AgentHarness({ - models, - session: blocking.session, - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - const nextModel = getModel("anthropic", "claude-haiku-4-5"); - const mutations = [ - harness.appendMessage(createUserMessage("concurrent")), - harness.setModel(nextModel), - harness.setThinkingLevel("high"), - ]; - await blocking.allWritesStarted.promise; - - harness.requestShutdown(); - const shutdown = harness.waitForShutdown(); - const firstSettlement = await Promise.race([ - shutdown.then(() => "shutdown" as const), - new Promise<"writes-pending">((resolve) => setImmediate(() => resolve("writes-pending"))), - ]); - - expect(firstSettlement).toBe("writes-pending"); - blocking.releaseWrites.resolve(); - await Promise.all([...mutations, shutdown]); - expect(await blocking.getEntries()).toHaveLength(3); - }); - - it("shuts down an idle harness without modifying its durable session", async () => { - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("existing")); - const harness = new AgentHarness({ - models, - session, - model: getModel("anthropic", "claude-sonnet-4-5"), - }); - - harness.requestShutdown(); - await harness.waitForShutdown(); - - const messages = (await session.getEntries()).flatMap((entry) => - entry.type === "message" ? [entry.message] : [], - ); - expect(messages).toEqual([expect.objectContaining({ role: "user" })]); - await expect(harness.compact()).rejects.toMatchObject({ code: "invalid_state" }); - }); - - it("drains one queued steering message at a time and emits queue updates", async () => { - const registration = newFaux(); - const userCounts: number[] = []; - registration.setResponses([ - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("first"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("second"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("third"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - steeringMode: "one-at-a-time", - }); - const steerQueueLengths: number[] = []; - let queued = false; - harness.subscribe((event) => { - if (event.type === "queue_update") { - steerQueueLengths.push(event.steer.length); - } - if (event.type === "message_start" && event.message.role === "assistant" && !queued) { - queued = true; - harness.steer("one"); - harness.steer("two"); - } - }); - - await harness.prompt("hello"); - - expect(userCounts).toEqual([1, 2, 3]); - expect(steerQueueLengths).toEqual([1, 2, 1, 0]); - }); - - it("appends before_agent_start messages and persists them", async () => { - const registration = newFaux(); - let requestText: string[] = []; - registration.setResponses([ - (context) => { - requestText = textFromUserMessages(context.messages); - return fauxAssistantMessage("ok"); - }, - ]); - const session = await createInMemorySession(); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - harness.on("before_agent_start", () => ({ - messages: [{ role: "user", content: [{ type: "text", text: "hook" }], timestamp: Date.now() }], - })); - - await harness.prompt("hello"); - - const persistedText = (await session.getEntries()).flatMap((entry) => { - if (entry.type !== "message" || entry.message.role !== "user") return []; - const content = entry.message.content; - if (typeof content === "string") return [content]; - return content.flatMap((part) => (part.type === "text" ? [part.text] : [])); - }); - expect(requestText).toEqual(["hello", "hook"]); - expect(persistedText).toEqual(["hello", "hook"]); - }); - - it("abort clears steer and follow-up queues but preserves next-turn messages", async () => { - const registration = newFaux(); - let releaseFirstResponse: (() => void) | undefined; - let abortedSignal: AbortSignal | undefined; - const firstResponseReleased = new Promise((resolve) => { - releaseFirstResponse = resolve; - }); - const secondRequestText: string[] = []; - registration.setResponses([ - async (_context, options) => { - abortedSignal = options?.signal; - await firstResponseReleased; - return fauxAssistantMessage("aborted-ish"); - }, - (context) => { - secondRequestText.push(...textFromUserMessages(context.messages)); - return fauxAssistantMessage("second"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - const queueUpdates: Array<{ steer: number; followUp: number; nextTurn: number }> = []; - harness.subscribe((event) => { - if (event.type === "queue_update") { - queueUpdates.push({ - steer: event.steer.length, - followUp: event.followUp.length, - nextTurn: event.nextTurn.length, - }); - } - }); - - const firstPrompt = harness.prompt("first"); - await new Promise((resolve) => setTimeout(resolve, 0)); - harness.steer("steer"); - harness.followUp("follow"); - harness.nextTurn("next"); - const abortResultPromise = harness.abort(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(abortedSignal?.aborted).toBe(true); - releaseFirstResponse?.(); - const abortResult = await abortResultPromise; - await firstPrompt; - await harness.prompt("second"); - - expect(abortResult.clearedSteer).toHaveLength(1); - expect(abortResult.clearedFollowUp).toHaveLength(1); - expect(queueUpdates).toContainEqual({ steer: 0, followUp: 0, nextTurn: 1 }); - expect(secondRequestText).toEqual(["first", "next", "second"]); - }); - - it("drains follow-up messages one at a time after the agent would otherwise stop", async () => { - const registration = newFaux(); - const userCounts: number[] = []; - registration.setResponses([ - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("first"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("second"); - }, - (context) => { - userCounts.push(context.messages.filter((message) => message.role === "user").length); - return fauxAssistantMessage("third"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - followUpMode: "one-at-a-time", - }); - const followUpQueueLengths: number[] = []; - let queued = false; - harness.subscribe((event) => { - if (event.type === "queue_update") { - followUpQueueLengths.push(event.followUp.length); - } - if (event.type === "message_start" && event.message.role === "assistant" && !queued) { - queued = true; - harness.followUp("one"); - harness.followUp("two"); - } - }); - - await harness.prompt("hello"); - - expect(userCounts).toEqual([1, 2, 3]); - expect(followUpQueueLengths).toEqual([1, 2, 1, 0]); - }); - - it("settles thrown hook failures with persisted assistant error messages", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("should not be used")]); - const session = await createInMemorySession(); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - const events: string[] = []; - harness.subscribe((event) => { - events.push(event.type); - }); - harness.on("context", () => { - throw new Error("context exploded"); - }); - - const response = await harness.prompt("hello"); - await expect(harness.prompt("after failure")).resolves.toMatchObject({ role: "assistant" }); - - const entries = await session.getEntries(); - const messages = entries.flatMap((entry) => (entry.type === "message" ? [entry.message] : [])); - expect(response.stopReason).toBe("error"); - expect(response.errorMessage).toBe("context exploded"); - expect(messages[0]?.role).toBe("user"); - expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "context exploded" }); - expect(events).toContain("agent_end"); - expect(events).toContain("settled"); - }); - - it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => { - const registration = newFaux({ - models: [ - { id: "first", reasoning: true }, - { id: "second", reasoning: true }, - ], - }); - const secondModel = registration.getModel("second"); - if (!secondModel) throw new Error("missing second faux model"); - const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = []; - registration.setResponses([ - (context, options, _state, model) => { - captured.push({ - modelId: model.id, - reasoning: getReasoning(options), - systemPrompt: context.systemPrompt ?? "", - tools: context.tools?.map((tool) => tool.name) ?? [], - }); - return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), { - stopReason: "toolUse", - }); - }, - (context, options, _state, model) => { - captured.push({ - modelId: model.id, - reasoning: getReasoning(options), - systemPrompt: context.systemPrompt ?? "", - tools: context.tools?.map((tool) => tool.name) ?? [], - }); - return fauxAssistantMessage("done"); - }, - ]); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - thinkingLevel: "off", - resources: { - skills: [{ name: "prompt", description: "prompt", content: "first prompt", filePath: "/skills/prompt" }], - }, - systemPrompt: ({ resources }) => resources.skills?.[0]?.content ?? "missing prompt", - tools: [calculateTool], - }); - harness.subscribe((event) => { - if (event.type === "tool_execution_start") { - void harness.setModel(secondModel); - void harness.setThinkingLevel("high"); - void harness.setResources({ - skills: [ - { name: "prompt", description: "prompt", content: "second prompt", filePath: "/skills/prompt" }, - ], - }); - void harness.setTools([calculateTool, getCurrentTimeTool], [getCurrentTimeTool.name]); - } - }); - - await harness.prompt("hello"); - - expect(captured).toEqual([ - { modelId: "first", reasoning: undefined, systemPrompt: "first prompt", tools: ["calculate"] }, - { modelId: "second", reasoning: "high", systemPrompt: "second prompt", tools: ["get_current_time"] }, - ]); - }); - - it("orders pending listener session writes after agent-emitted messages", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("ok")]); - const session = await createInMemorySession(); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - let wrotePendingMessage = false; - harness.subscribe(async (event) => { - if (event.type === "message_end" && event.message.role === "assistant" && !wrotePendingMessage) { - wrotePendingMessage = true; - await harness.appendMessage({ - role: "custom", - customType: "listener", - content: "listener write", - display: true, - timestamp: Date.now(), - } as AgentMessage); - } - }); - - await harness.prompt("hello"); - - const entries = await session.getEntries(); - const roles = entries.flatMap((entry) => (entry.type === "message" ? [entry.message.role] : [])); - expect(roles).toEqual(["user", "assistant", "custom"]); - }); - - it("waitForIdle waits for external run settlement and awaited listeners", async () => { - const registration = newFaux(); - registration.setResponses([() => fauxAssistantMessage("ok")]); - const barrier = deferred(); - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - }); - let listenerFinished = false; - harness.subscribe(async (event) => { - if (event.type === "agent_end") { - await barrier.promise; - listenerFinished = true; - } - }); - - const promptPromise = harness.prompt("hello"); - let idleResolved = false; - const idlePromise = harness.waitForIdle().then(() => { - idleResolved = true; - }); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(idleResolved).toBe(false); - expect(listenerFinished).toBe(false); - barrier.resolve(); - await Promise.all([promptPromise, idlePromise]); - expect(idleResolved).toBe(true); - expect(listenerFinished).toBe(true); - }); - - it("runs tool_call and tool_result hooks through the direct loop", async () => { - const registration = newFaux(); - registration.setResponses([ - () => - fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), { - stopReason: "toolUse", - }), - ]); - const session = await createInMemorySession(); - const toolUsage = createUsage(1, 2, 3, 4); - const patchedToolUsage = createUsage(5, 6, 7, 8); - const calculateToolWithUsage = createCalculateToolWithUsage(toolUsage); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - tools: [calculateToolWithUsage], - }); - const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = []; - let seenToolUsage: Usage | undefined; - harness.on("tool_call", (event) => { - seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression }); - return undefined; - }); - harness.on("tool_result", (event) => { - expect(event.toolCallId).toBe("call-1"); - expect(event.toolName).toBe("calculate"); - seenToolUsage = event.usage; - return { - content: [{ type: "text", text: "patched result" }], - details: { patched: true }, - usage: patchedToolUsage, - terminate: true, - }; - }); - - await harness.prompt("hello"); - - const toolResult = (await session.getEntries()).find( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]); - expect(seenToolUsage).toEqual(toolUsage); - expect(toolResult).toMatchObject({ - type: "message", - message: { - role: "toolResult", - content: [{ type: "text", text: "patched result" }], - details: { patched: true }, - usage: patchedToolUsage, - }, - }); - }); - - it("passes a static application context to harness tools", async () => { - const registration = newFaux(); - registration.setResponses([ - () => - fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-1" }), { - stopReason: "toolUse", - }), - ]); - const env = new NodeExecutionEnv({ cwd: process.cwd() }); - const toolContext = { env }; - let receivedContext: typeof toolContext | undefined; - const contextTool: AgentHarnessTool = { - ...calculateTool, - name: "context", - execute: async (toolCallId, params, signal, onUpdate, context) => { - receivedContext = context; - return { ...(await calculateTool.execute(toolCallId, params, signal, onUpdate)), terminate: true }; - }, - }; - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - tools: [contextTool], - toolContext, - }); - - await harness.prompt("hello"); - - expect(receivedContext).toBe(toolContext); - }); - - it("resolves async tool context providers for each turn snapshot", async () => { - const registration = newFaux(); - registration.setResponses([ - () => - fauxAssistantMessage(fauxToolCall("context", { expression: "1 + 1" }, { id: "call-1" }), { - stopReason: "toolUse", - }), - () => - fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-2" }), { - stopReason: "toolUse", - }), - () => fauxAssistantMessage("done"), - ]); - type ToolContext = { generation: number }; - const generations: number[] = []; - const contextTool: AgentHarnessTool = { - ...calculateTool, - name: "context", - execute: async (toolCallId, params, signal, onUpdate, context) => { - generations.push(context.generation); - return await calculateTool.execute(toolCallId, params, signal, onUpdate); - }, - }; - let generation = 0; - const harness = new AgentHarness({ - models, - session: await createInMemorySession(), - model: registration.getModel(), - tools: [contextTool], - toolContext: async (): Promise => ({ generation: ++generation }), - }); - - await harness.prompt("hello"); - - expect(generations).toEqual([1, 2]); - }); - - it("persists generated compaction usage", async () => { - const registration = newFaux(); - registration.setResponses([fauxAssistantMessage("## Goal\nTest summary")]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - - const result = await harness.compact(); - const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction"); - - expect(result.usage?.totalTokens).toBeGreaterThan(0); - expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(result.usage); - }); - - it("persists hook-provided compaction usage", async () => { - const registration = newFaux(); - const usage = createUsage(5, 6, 7, 8); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - harness.on("session_before_compact", (event) => ({ - compaction: { - summary: "hook summary", - firstKeptEntryId: event.preparation.firstKeptEntryId, - tokensBefore: event.preparation.tokensBefore, - usage, - }, - })); - - const result = await harness.compact(); - const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction"); - - expect(result.usage).toEqual(usage); - expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage); - }); - - describe("summarization retries", () => { - it("retries transient compaction errors and emits retry events", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses([ - () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }); - }, - () => { - calls++; - return fauxAssistantMessage("## Goal\nRecovered summary"); - }, - ]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(`${event.type}:${event.operation}`); - } - }); - - const result = await harness.compact(); - - expect(result.summary).toContain("Recovered summary"); - expect(calls).toBe(2); - expect(retryEvents).toEqual([ - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_finished:compaction", - ]); - }); - - it("does not retry non-retryable compaction errors", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses([ - () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }); - }, - ]); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(event.type); - } - }); - - await expect(harness.compact()).rejects.toThrow("insufficient_quota"); - - expect(calls).toBe(1); - expect(retryEvents).toEqual([]); - }); - - it("exhausts transient compaction retries after maxRetries failures", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses( - Array.from({ length: 4 }, () => () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }); - }), - ); - const session = await createInMemorySession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(`${event.type}:${event.operation}`); - } - }); - - await expect(harness.compact()).rejects.toThrow("terminated"); - - expect(calls).toBe(4); - expect(retryEvents).toEqual([ - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_scheduled:compaction", - "retry_attempt_start:compaction", - "retry_finished:compaction", - ]); - }); - - it("retries transient branch summary errors and emits retry events", async () => { - const registration = newFaux(); - let calls = 0; - registration.setResponses([ - () => { - calls++; - return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }); - }, - () => { - calls++; - return fauxAssistantMessage("## Goal\nRecovered branch summary"); - }, - ]); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 }, - }); - const retryEvents: string[] = []; - harness.subscribe((event) => { - if ( - event.type === "retry_scheduled" || - event.type === "retry_attempt_start" || - event.type === "retry_finished" - ) { - retryEvents.push(`${event.type}:${event.operation}`); - } - }); - - const result = await harness.navigateTree(targetId, { summarize: true }); - - expect(result.summaryEntry?.summary).toContain("Recovered branch summary"); - expect(calls).toBe(2); - expect(retryEvents).toEqual([ - "retry_scheduled:branch_summary", - "retry_attempt_start:branch_summary", - "retry_finished:branch_summary", - ]); - }); - }); - - it("persists generated branch summary usage", async () => { - const registration = newFaux(); - registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - - const result = await harness.navigateTree(targetId, { summarize: true }); - - expect(result.summaryEntry?.usage?.totalTokens).toBeGreaterThan(0); - }); - - it("persists hook-provided branch summary usage", async () => { - const registration = newFaux(); - const usage = createUsage(13, 14, 15, 16); - const session = await createInMemorySession(); - const targetId = await session.appendMessage(createUserMessage("first branch")); - await session.appendMessage(createAssistantMessage("first reply")); - await session.appendMessage(createUserMessage("abandoned work")); - await session.appendMessage(createAssistantMessage("abandoned reply")); - const harness = new AgentHarness({ - models, - session, - model: registration.getModel(), - }); - harness.on("session_before_tree", () => ({ - summary: { summary: "hook branch summary", usage }, - })); - - const result = await harness.navigateTree(targetId, { summarize: true }); - - expect(result.summaryEntry?.usage).toEqual(usage); - }); - - it("preserves app tool types for getters and update events", async () => { - const session = await createInMemorySession(); - const model = getModel("anthropic", "claude-sonnet-4-5"); - type AppTool = AgentTool & { source: "builtin" | "extension" }; - const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" }; - const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" }; - const harness = new AgentHarness({ - models, - session, - model, - tools: [inspectTool, searchTool], - activeToolNames: ["inspect"], - }); - const updates: Array<{ - toolNames: string[]; - previousToolNames: string[]; - activeToolNames: string[]; - previousActiveToolNames: string[]; - source: "set" | "restore"; - }> = []; - harness.subscribe((event) => { - if (event.type === "tools_update") { - updates.push({ - toolNames: event.toolNames, - previousToolNames: event.previousToolNames, - activeToolNames: event.activeToolNames, - previousActiveToolNames: event.previousActiveToolNames, - source: event.source, - }); - expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames); - } - }); - - const tools = harness.getTools(); - const activeTools = harness.getActiveTools(); - tools.pop(); - activeTools.pop(); - expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]); - expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]); - - await harness.setActiveTools(["search"]); - await harness.setTools([searchTool], ["search"]); - await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" }); - await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" }); - await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" }); - await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({ - code: "invalid_argument", - }); - - expect(updates).toEqual([ - { - toolNames: ["inspect", "search"], - previousToolNames: ["inspect", "search"], - activeToolNames: ["search"], - previousActiveToolNames: ["inspect"], - source: "set", - }, - { - toolNames: ["search"], - previousToolNames: ["inspect", "search"], - activeToolNames: ["search"], - previousActiveToolNames: ["search"], - source: "set", - }, - ]); - expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]); - expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]); - expect((await session.buildContext()).activeToolNames).toEqual(["search"]); - }); - - it("validates constructor tool names", async () => { - const session = await createInMemorySession(); - const model = getModel("anthropic", "claude-sonnet-4-5"); - expect( - () => new AgentHarness({ session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }), - ).toThrow(/Unknown tool/); - expect( - () => - new AgentHarness({ - models, - session, - model, - tools: [calculateTool, calculateTool], - activeToolNames: [calculateTool.name], - }), - ).toThrow(/Duplicate tool/); - expect( - () => - new AgentHarness({ - models, - session, - model, - tools: [calculateTool], - activeToolNames: [calculateTool.name, calculateTool.name], - }), - ).toThrow(/Duplicate active tool/); - }); - - it("preserves app resource types for getters and update events", async () => { - const session = await createInMemorySession(); - const model = getModel("anthropic", "claude-sonnet-4-5"); - const harness = new AgentHarness({ - session, - models, - model, - }); - const skill: AppSkill = { - name: "inspect", - description: "Inspect things", - content: "Use inspection tools.", - filePath: "/skills/inspect/SKILL.md", - source: "project", - }; - const promptTemplate: AppPromptTemplate = { name: "review", content: "Review $1", source: "user" }; - const resources = { skills: [skill], promptTemplates: [promptTemplate] }; - const updates: Array<{ resourcesSource?: string; previousSource?: string }> = []; - harness.subscribe((event) => { - if (event.type === "resources_update") { - updates.push({ - resourcesSource: event.resources.skills?.[0]?.source, - previousSource: event.previousResources.skills?.[0]?.source, - }); - } - }); - - await harness.setResources(resources); - await harness.setResources(resources); - const resolved = harness.getResources(); - - expect(updates).toEqual([ - { resourcesSource: "project", previousSource: undefined }, - { resourcesSource: "project", previousSource: "project" }, - ]); - expect(resolved.skills?.[0]?.source).toBe("project"); - expect(resolved.promptTemplates?.[0]?.source).toBe("user"); - expect(resolved.skills).not.toBe(resources.skills); - expect(resolved.promptTemplates).not.toBe(resources.promptTemplates); - }); -}); diff --git a/packages/agent/test/harness/branch-query.test.ts b/packages/agent/test/harness/branch-query.test.ts deleted file mode 100644 index fb2f4b062ae..00000000000 --- a/packages/agent/test/harness/branch-query.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createNodeSqliteFactory, SqliteSessionRepository } from "../../../storage/sqlite-node/src/index.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { JsonlSessionRepository } from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionBackend, InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import type { Session } from "../../src/harness/session/session.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -const ownedRepositories: AsyncDisposable[] = []; - -afterEach(async () => { - for (const repository of ownedRepositories.splice(0)) await repository[Symbol.asyncDispose](); -}); - -async function verifyBranchQueries(session: Session): Promise<{ tail: string; fullPath: string[] }> { - const root = await session.appendMessage(createUserMessage("root")); - const custom = await session.appendCustomEntry("note", { value: 1 }); - const child = await session.appendMessage(createAssistantMessage("child")); - const compaction = await session.appendCompaction("summary", child, 100, undefined, undefined, undefined, [ - createAssistantMessage("child"), - ]); - const recentCustom = await session.appendCustomEntry("note", { value: 2 }); - const tail = await session.appendMessage(createUserMessage("tail")); - await session.moveTo(root); - const sibling = await session.appendMessage(createUserMessage("sibling")); - - expect((await session.findEntriesOnBranch()).map((entry) => entry.id)).toEqual([sibling, root]); - expect(await session.findEntriesOnBranch({ start: null })).toEqual([]); - expect((await session.findEntriesOnBranch({ start: tail, order: "oldestFirst" })).map((entry) => entry.id)).toEqual([ - root, - custom, - child, - compaction, - recentCustom, - tail, - ]); - expect( - (await session.findEntriesOnBranch({ start: tail, stopAtType: "compaction" })).map((entry) => entry.id), - ).toEqual([tail, recentCustom, compaction]); - expect( - (await session.findEntriesOnBranch({ start: tail, stopAtType: "compaction", type: "message" })).map( - (entry) => entry.id, - ), - ).toEqual([tail]); - expect( - (await session.findEntriesOnBranch({ start: tail, stopAtId: child, order: "oldestFirst" })).map( - (entry) => entry.id, - ), - ).toEqual([root, custom, child]); - expect((await session.findEntriesOnBranch({ start: tail, stopAtType: "custom" })).map((entry) => entry.id)).toEqual([ - tail, - recentCustom, - ]); - expect( - ( - await session.findEntriesOnBranch({ - start: tail, - stopAtType: "custom", - order: "oldestFirst", - }) - ).map((entry) => entry.id), - ).toEqual([root, custom]); - expect( - (await session.findEntriesOnBranch({ start: tail, type: "message", order: "oldestFirst" })).map( - (entry) => entry.id, - ), - ).toEqual([root, child, tail]); - expect((await session.findEntriesOnBranch({ start: tail, customType: "note" })).map((entry) => entry.id)).toEqual([ - recentCustom, - custom, - ]); - expect((await session.findEntriesOnBranch({ start: tail, limit: 1 })).map((entry) => entry.id)).toEqual([tail]); - expect( - ( - await session.findEntriesOnBranch({ - start: tail, - type: "message", - order: "oldestFirst", - limit: 1, - }) - ).map((entry) => entry.id), - ).toEqual([root]); - expect(await session.findEntryOnBranch({ start: tail, type: "compaction" })).toMatchObject({ id: compaction }); - await expect(session.findEntriesOnBranch({ start: "missing" })).rejects.toMatchObject({ code: "not_found" }); - await expect(session.findEntriesOnBranch({ limit: 0 })).rejects.toThrow("limit must be a positive integer"); - return { tail, fullPath: [root, custom, child, compaction, recentCustom, tail] }; -} - -describe("bounded session branch queries", () => { - it("provides identical in-memory query semantics", async () => { - const repo = new InMemorySessionRepository(); - ownedRepositories.push(repo); - const session = await repo.create({ id: "memory" }); - const expected = await verifyBranchQueries(session); - const reopened = await repo.open(await session.getMetadata()); - expect( - (await reopened.findEntriesOnBranch({ start: expected.tail, order: "oldestFirst" })).map((entry) => entry.id), - ).toEqual(expected.fullPath); - }); - - it("rejects corrupt parent chains in array-backed readers", async () => { - const backend = new InMemorySessionBackend(); - ownedRepositories.push(backend); - const storage = await backend.create({ id: "corrupt-memory" }); - await storage.appendEntry({ - type: "message", - id: "orphan", - parentId: "missing-parent", - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage("orphan"), - }); - - expect( - (await storage.findEntriesOnBranch({ start: "orphan", stopAtId: "orphan" })).map((entry) => entry.id), - ).toEqual(["orphan"]); - expect( - (await storage.findEntriesOnBranch({ start: "orphan", stopAtType: "message" })).map((entry) => entry.id), - ).toEqual(["orphan"]); - await expect(storage.findEntriesOnBranch({ start: "orphan" })).rejects.toMatchObject({ - code: "invalid_session", - message: "Entry missing-parent not found", - }); - await storage.appendEntry({ - type: "message", - id: "cycle-a", - parentId: "cycle-b", - timestamp: "2026-01-01T00:00:01.000Z", - message: createUserMessage("a"), - }); - await storage.appendEntry({ - type: "message", - id: "cycle-b", - parentId: "cycle-a", - timestamp: "2026-01-01T00:00:02.000Z", - message: createUserMessage("b"), - }); - await expect(storage.findEntriesOnBranch({ start: "cycle-b" })).rejects.toMatchObject({ - code: "invalid_session", - message: "Session branch contains a cycle at cycle-b", - }); - }); - - it("provides identical JSONL query semantics", async () => { - const root = createTempDir(); - const repo = new JsonlSessionRepository({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "jsonl", cwd: root }); - const expected = await verifyBranchQueries(session); - const reopened = await repo.open(await session.getMetadata()); - expect( - (await reopened.findEntriesOnBranch({ start: expected.tail, order: "oldestFirst" })).map((entry) => entry.id), - ).toEqual(expected.fullPath); - }); - - it("does not decode SQLite branch entries outside query bounds", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath, - }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "bounded-sqlite", cwd: root }); - const rootId = await session.appendMessage(createUserMessage("root")); - const middleId = await session.appendMessage(createAssistantMessage("middle")); - const tailId = await session.appendMessage(createUserMessage("tail")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "bounded-sqlite", middleId); - const branch = await db - .prepare("SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ?") - .get<{ branch_id: string }>("bounded-sqlite", tailId); - if (!branch) throw new Error("Missing branch cache for bounded SQLite query test"); - await db - .prepare("DELETE FROM branch_entries WHERE session_id = ? AND branch_id = ? AND entry_id = ?") - .run("bounded-sqlite", branch.branch_id, middleId); - } finally { - await db.close(); - } - - expect((await session.findEntriesOnBranch({ start: tailId, stopAtId: tailId })).map((entry) => entry.id)).toEqual( - [tailId], - ); - const inspection = await sqlite.open(databasePath); - try { - const repaired = await inspection - .prepare( - `SELECT entry_id FROM branch_entries - WHERE session_id = ? AND branch_id = ( - SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ? LIMIT 1 - ) - ORDER BY entry_seq`, - ) - .all<{ entry_id: string }>("bounded-sqlite", "bounded-sqlite", tailId); - expect(repaired.map((row) => row.entry_id)).toEqual([rootId, tailId]); - } finally { - await inspection.close(); - } - expect( - ( - await session.findEntriesOnBranch({ - start: tailId, - stopAtId: rootId, - order: "oldestFirst", - limit: 1, - }) - ).map((entry) => entry.id), - ).toEqual([rootId]); - await expect(session.findEntriesOnBranch({ start: tailId, limit: 2 })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${middleId}`), - }); - }); - - it("validates SQLite entries before filtering and limiting branch results", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath, - }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "invalid-filtered-sqlite", cwd: root }); - await session.appendMessage(createUserMessage("root")); - const customId = await session.appendCustomEntry("note", { value: 1 }); - const tailId = await session.appendMessage(createAssistantMessage("tail")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("{}", "invalid-filtered-sqlite", customId); - } finally { - await db.close(); - } - await expect(session.findEntriesOnBranch({ start: tailId, type: "message", limit: 1 })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); - - const invalidJsonDb = await sqlite.open(databasePath); - try { - await invalidJsonDb - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "invalid-filtered-sqlite", customId); - } finally { - await invalidJsonDb.close(); - } - await expect(session.findEntriesOnBranch({ start: tailId, customType: "other" })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`failed to decode entry ${customId}`), - }); - }); - - it("does not validate SQLite ancestors beyond newest-first stop bounds", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath, - }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "bounded-corrupt-sqlite", cwd: root }); - const rootId = await session.appendMessage(createUserMessage("root")); - const childId = await session.appendMessage(createAssistantMessage("child")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE session_entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run("missing-parent", "bounded-corrupt-sqlite", childId); - } finally { - await db.close(); - } - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtId: childId })).map((entry) => entry.id), - ).toEqual([childId]); - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), - ).toEqual([childId]); - await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_session", - message: expect.stringContaining("Entry missing-parent not found"), - }); - - const cycleDb = await sqlite.open(databasePath); - try { - await cycleDb - .prepare("UPDATE session_entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run(rootId, "bounded-corrupt-sqlite", childId); - await cycleDb - .prepare("UPDATE session_entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run(childId, "bounded-corrupt-sqlite", rootId); - } finally { - await cycleDb.close(); - } - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtId: childId })).map((entry) => entry.id), - ).toEqual([childId]); - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), - ).toEqual([childId]); - await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_session", - message: expect.stringContaining(`cycle in parent chain at entry ${childId}`), - }); - }); - - it("provides identical SQLite query semantics", async () => { - const root = createTempDir(); - const repo = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite: createNodeSqliteFactory(), - databasePath: join(root, "sessions.sqlite"), - }); - ownedRepositories.push(repo); - const session = await repo.create({ id: "sqlite", cwd: root }); - const expected = await verifyBranchQueries(session); - const reopened = await repo.open(await session.getMetadata()); - expect( - (await reopened.findEntriesOnBranch({ start: expected.tail, order: "oldestFirst" })).map((entry) => entry.id), - ).toEqual(expected.fullPath); - }); -}); diff --git a/packages/agent/test/harness/branch-summarization.test.ts b/packages/agent/test/harness/branch-summarization.test.ts new file mode 100644 index 00000000000..0e690405f8a --- /dev/null +++ b/packages/agent/test/harness/branch-summarization.test.ts @@ -0,0 +1,39 @@ +import type { AgentMessage } from "@cheetahbyte/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { collectEntriesForBranchSummary } from "../../src/harness/compaction/branch-summarization.ts"; +import { InMemorySessionStorage, Session } from "../../src/harness/session/index.ts"; + +function message(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +describe("v4 branch summarization", () => { + it("collects the abandoned side of a branch in chronological order", async () => { + let nextId = 0; + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 }), { + idGenerator: { next: () => `entry-${++nextId}` }, + }); + const rootId = await session.appendMessage(message("root")); + const commonId = await session.appendMessage(message("common")); + const abandonedIds = [ + await session.appendMessage(message("abandoned 1")), + await session.appendMessage(message("abandoned 2")), + ]; + await session.createLane("target", commonId); + const targetId = await session.view("target").appendMessage(message("target")); + + const result = await collectEntriesForBranchSummary(session, abandonedIds[1]!, targetId); + expect(result.commonAncestorId).toBe(commonId); + expect(result.entries.map((entry) => entry.id)).toEqual(abandonedIds); + expect(result.entries.some((entry) => entry.id === rootId)).toBe(false); + }); + + it("returns no entries when there was no previous leaf", async () => { + const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 })); + const targetId = await session.appendMessage(message("target")); + expect(await collectEntriesForBranchSummary(session, null, targetId)).toEqual({ + entries: [], + commonAncestorId: null, + }); + }); +}); diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index 19f2e9f789f..4a4b2f81852 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -1,4 +1,5 @@ import { + type Api, type AssistantMessage, createModels, type FauxProviderHandle, @@ -12,6 +13,7 @@ import { import { beforeEach, describe, expect, it } from "vitest"; import { type CompactionPreparation, + type CompactionSettings, calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, @@ -26,17 +28,15 @@ import { serializeConversation, shouldCompact, } from "../../src/harness/compaction/compaction.ts"; -import { buildSessionContext } from "../../src/harness/session/session.ts"; +import { buildSessionContext } from "../../src/harness/session/context.ts"; import type { BranchSummaryEntry, CompactionEntry, - CompactionSettings, - CustomMessageEntry, + Entry, MessageEntry, ModelChangeEntry, - SessionTreeEntry, - ThinkingLevelChangeEntry, -} from "../../src/harness/types.ts"; + ThinkingLevelEntry, +} from "../../src/harness/session/types.ts"; import { getOrThrow } from "../../src/harness/types.ts"; import type { AgentMessage } from "../../src/types.ts"; @@ -82,14 +82,14 @@ function createMessageEntry(message: AgentMessage, parentId: string | null = nul type: "message", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), message, }; } function createCompactionEntry( summary: string, - firstKeptEntryId: string, parentId: string | null = null, retainedTail?: AgentMessage[], ): CompactionEntry { @@ -97,20 +97,21 @@ function createCompactionEntry( type: "compaction", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), summary, - firstKeptEntryId, tokensBefore: 1234, - retainedTail, + retainedTail: retainedTail ?? [], }; } -function createThinkingLevelEntry(level: string, parentId: string | null = null): ThinkingLevelChangeEntry { +function createThinkingLevelEntry(level: string, parentId: string | null = null): ThinkingLevelEntry { return { type: "thinking_level_change", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), thinkingLevel: level, }; } @@ -120,7 +121,8 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str type: "model_change", id: createId(), parentId, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), provider, modelId, }; @@ -130,7 +132,7 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str const models = createModels(); let fauxCount = 0; -function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProviderHandle; model: Model } { +function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProviderHandle; model: Model } { const faux = fauxProvider({ provider: `faux-${++fauxCount}`, models: [ @@ -179,7 +181,7 @@ describe("harness compaction", () => { }); it("finds a cut point based on token differences", () => { - const entries: SessionTreeEntry[] = []; + const entries: Entry[] = []; let parentId: string | null = null; for (let i = 0; i < 10; i++) { const user = createMessageEntry(createUserMessage(`User ${i}`), parentId); @@ -209,24 +211,15 @@ describe("harness compaction", () => { type: "branch_summary", id: createId(), parentId: modelChange.id, - timestamp: new Date().toISOString(), + seq: nextId, + timestamp: Date.now(), fromId: "branch", summary: "branch summary", }; - const customMessage: CustomMessageEntry = { - type: "custom_message", - id: createId(), - parentId: branchSummary.id, - timestamp: new Date().toISOString(), - customType: "note", - content: "custom content", - display: true, - }; expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1); - expect(findTurnStartIndex([thinking, customMessage], 1, 0)).toBe(1); expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1); - const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1); + const result = findCutPoint([thinking, branchSummary], 0, 2, 1); expect(result.firstKeptEntryIndex).toBe(0); const toolResult = createMessageEntry({ @@ -244,7 +237,7 @@ describe("harness compaction", () => { }); const user = createMessageEntry(createUserMessage("user")); - const compaction = createCompactionEntry("summary", user.id, user.id); + const compaction = createCompactionEntry("summary", user.id); const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id); expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2); }); @@ -345,7 +338,7 @@ describe("harness compaction", () => { const a1 = createMessageEntry(createAssistantMessage("a"), u1.id); const u2 = createMessageEntry(createUserMessage("2"), a1.id); const a2 = createMessageEntry(createAssistantMessage("b"), u2.id); - const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id, [ + const compaction = createCompactionEntry("Summary of 1,a,2,b", a2.id, [ createUserMessage("2"), createAssistantMessage("b"), ]); @@ -363,22 +356,6 @@ describe("harness compaction", () => { ]); }); - it("falls back to firstKeptEntryId when a compaction has no retained tail", () => { - const u1 = createMessageEntry(createUserMessage("1")); - const a1 = createMessageEntry(createAssistantMessage("a"), u1.id); - const u2 = createMessageEntry(createUserMessage("2"), a1.id); - const a2 = createMessageEntry(createAssistantMessage("b"), u2.id); - const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id); - const u3 = createMessageEntry(createUserMessage("3"), compaction.id); - const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3]); - expect(loaded.messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - "assistant", - "user", - ]); - }); - it("tracks model and thinking level changes in built context", () => { const user = createMessageEntry(createUserMessage("1")); const modelChange = createModelChangeEntry("openai", "gpt-4", user.id); @@ -394,18 +371,61 @@ describe("harness compaction", () => { const a1 = createMessageEntry(createAssistantMessage("assistant msg 1"), u1.id); const u2 = createMessageEntry(createUserMessage("user msg 2"), a1.id); const a2 = createMessageEntry(createAssistantMessage("assistant msg 2", createMockUsage(5000, 1000)), u2.id); - const compaction1 = createCompactionEntry("First summary", u2.id, a2.id); + const compaction1 = createCompactionEntry("First summary", a2.id); const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id); const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id); const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3]; const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS)); expect(preparation).toBeDefined(); expect(preparation?.previousSummary).toBe("First summary"); - expect(preparation?.firstKeptEntryId).toBeTruthy(); expect(preparation?.retainedTail.length).toBeGreaterThan(0); expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens); }); + it("carries a previous compaction's retained tail into the next preparation", () => { + const retainedUser = createUserMessage("retained user"); + const retainedAssistant = createAssistantMessage("retained assistant"); + const compaction = createCompactionEntry("previous summary", null, [retainedUser, retainedAssistant]); + const user = createMessageEntry(createUserMessage("new user"), compaction.id); + const assistant = createMessageEntry(createAssistantMessage("new assistant"), user.id); + + const preparation = getOrThrow( + prepareCompaction([compaction, user, assistant], { + enabled: true, + reserveTokens: 100, + keepRecentTokens: 1, + }), + ); + expect(preparation?.previousSummary).toBe("previous summary"); + expect([ + ...(preparation?.messagesToSummarize ?? []), + ...(preparation?.turnPrefixMessages ?? []), + ...(preparation?.retainedTail ?? []), + ]).toEqual([retainedUser, retainedAssistant, user.message, assistant.message]); + }); + + it("handles a previous compaction without a retained tail", () => { + const compaction = createCompactionEntry("previous summary", null); + const { retainedTail: _retainedTail, ...legacyCompaction } = compaction; + const user = createMessageEntry(createUserMessage("new user"), compaction.id); + const assistant = createMessageEntry(createAssistantMessage("new assistant"), user.id); + + const preparation = getOrThrow( + prepareCompaction([legacyCompaction as CompactionEntry, user, assistant], { + enabled: true, + reserveTokens: 100, + keepRecentTokens: 1, + }), + ); + + expect(preparation?.previousSummary).toBe("previous summary"); + expect([ + ...(preparation?.messagesToSummarize ?? []), + ...(preparation?.turnPrefixMessages ?? []), + ...(preparation?.retainedTail ?? []), + ]).toEqual([user.message, assistant.message]); + }); + it("prepares split-turn compaction with prior file-operation details", () => { const u1 = createMessageEntry(createUserMessage("user msg 1")); const assistantMessage: AssistantMessage = { @@ -414,8 +434,8 @@ describe("harness compaction", () => { }; const a1 = createMessageEntry(assistantMessage, u1.id); const compaction1: CompactionEntry = { - ...createCompactionEntry("First summary", u1.id, a1.id), - details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts"] }, + ...createCompactionEntry("First summary", a1.id), + details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts", "written.ts"] }, }; const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id); const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id); @@ -431,42 +451,11 @@ describe("harness compaction", () => { expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]); expect([...preparation!.fileOps.read]).toContain("old-read.ts"); expect([...preparation!.fileOps.edited]).toContain("old-edit.ts"); - expect([...preparation!.fileOps.written]).toContain("written.ts"); - }); - - it("prepares custom and branch summary entries for summarization", () => { - const branchSummary: BranchSummaryEntry = { - type: "branch_summary", - id: createId(), - parentId: null, - timestamp: new Date().toISOString(), - fromId: "branch", - summary: "branch summary", - }; - const customMessage: CustomMessageEntry = { - type: "custom_message", - id: createId(), - parentId: branchSummary.id, - timestamp: new Date().toISOString(), - customType: "note", - content: "custom content", - display: true, - }; - const user = createMessageEntry(createUserMessage("keep"), customMessage.id); - const assistant = createMessageEntry(createAssistantMessage("assistant"), user.id); - const preparation = getOrThrow( - prepareCompaction([branchSummary, customMessage, user, assistant], { - enabled: true, - reserveTokens: 100, - keepRecentTokens: 1, - }), - ); - - expect(preparation?.messagesToSummarize.map((message) => message.role)).toEqual(["branchSummary", "custom"]); + expect([...preparation!.fileOps.edited]).toContain("written.ts"); }); it("does not prepare compaction when there is nothing valid to compact", () => { - const compaction = createCompactionEntry("already compacted", "entry-keep"); + const compaction = createCompactionEntry("already compacted"); expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined(); expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined(); }); @@ -592,7 +581,6 @@ describe("harness compaction", () => { }, ]); const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: messages, retainedTail: messages, @@ -613,7 +601,6 @@ describe("harness compaction", () => { it("returns compaction error results without throwing", async () => { const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: [], retainedTail: messages, @@ -628,14 +615,6 @@ describe("harness compaction", () => { ok: false, error: { code: "summarization_failed", message: "Summarization failed: history failed" }, }); - - const { model: invalidModel } = createFauxModel(false); - const invalidResult = await compact( - { ...preparation, messagesToSummarize: [], firstKeptEntryId: "" }, - models, - invalidModel, - ); - expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } }); }); it("combines usage for split-turn compaction summaries", async () => { @@ -648,7 +627,6 @@ describe("harness compaction", () => { { ...fauxAssistantMessage("turn prefix summary"), usage: turnPrefixUsage }, ]); const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: messages, isSplitTurn: true, @@ -674,7 +652,6 @@ describe("harness compaction", () => { }, ]); const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: [], turnPrefixMessages: messages, retainedTail: messages, @@ -692,7 +669,6 @@ describe("harness compaction", () => { it("returns turn-prefix compaction errors without throwing", async () => { const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; const preparation: CompactionPreparation = { - firstKeptEntryId: "entry-keep", messagesToSummarize: [], turnPrefixMessages: messages, retainedTail: messages, @@ -732,7 +708,6 @@ describe("harness compaction", () => { faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]); const result = getOrThrow(await compact(preparation!, models, model)); expect(result.summary.length).toBeGreaterThan(0); - expect(result.firstKeptEntryId).toBeTruthy(); expect(result.usage?.totalTokens).toBeGreaterThan(0); expect(result.retainedTail?.length).toBeGreaterThan(0); expect(result.details).toBeDefined(); diff --git a/packages/agent/test/harness/repo.test.ts b/packages/agent/test/harness/repo.test.ts deleted file mode 100644 index 5379a77ef3b..00000000000 --- a/packages/agent/test/harness/repo.test.ts +++ /dev/null @@ -1,645 +0,0 @@ -import { existsSync, writeFileSync } from "node:fs"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { JsonlSessionBackend, JsonlSessionRepository } from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionBackend, InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import { createSession } from "../../src/harness/session/session.ts"; -import type { SessionForkSelection, SessionMetadata, SessionStorage } from "../../src/harness/types.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -afterEach(() => { - vi.useRealTimers(); -}); - -class CountingReadEnv extends NodeExecutionEnv { - readCount = 0; - - override async readTextFile(path: string, abortSignal?: AbortSignal) { - this.readCount += 1; - return super.readTextFile(path, abortSignal); - } -} - -function createDeferred(): Deferred { - let resolve!: () => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -interface Deferred { - promise: Promise; - resolve: () => void; -} - -function settlesBeforeNextTurn(promise: Promise): Promise { - return Promise.race([ - promise.then(() => true), - new Promise((resolve) => setImmediate(() => resolve(false))), - ]); -} - -class PerPathBlockingAppendEnv extends NodeExecutionEnv { - private readonly appendBlocks = new Map(); - private listBlock: { started: Deferred; release: Deferred } | undefined; - readonly appendCounts = new Map(); - - blockAppend(path: string): { started: Deferred; release: Deferred } { - const block = { started: createDeferred(), release: createDeferred() }; - this.appendBlocks.set(path, block); - return block; - } - - blockNextList(): { started: Deferred; release: Deferred } { - const block = { started: createDeferred(), release: createDeferred() }; - this.listBlock = block; - return block; - } - - override async exists(path: string) { - if (this.appendBlocks.has(path)) return { ok: true as const, value: true }; - return super.exists(path); - } - - override async appendFile(path: string, content: string | Uint8Array) { - this.appendCounts.set(path, (this.appendCounts.get(path) ?? 0) + 1); - const block = this.appendBlocks.get(path); - if (block) { - block.started.resolve(); - await block.release.promise; - } - return super.appendFile(path, content); - } - - override async listDir(path: string) { - const block = this.listBlock; - if (block) { - this.listBlock = undefined; - block.started.resolve(); - await block.release.promise; - } - return super.listDir(path); - } -} - -function createEntry(id: string) { - return { - type: "message" as const, - id, - parentId: null, - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage(id), - }; -} - -class BlockingCreateEnv extends NodeExecutionEnv { - readonly firstWriteStarted = createDeferred(); - readonly secondWriteStarted = createDeferred(); - readonly releaseFirstWrite = createDeferred(); - readonly writtenPaths = new Set(); - readonly writePaths: string[] = []; - - override async createDir() { - return { ok: true as const, value: undefined }; - } - - override async exists(path: string) { - return { ok: true as const, value: this.writtenPaths.has(path) }; - } - - override async writeFile(path: string) { - this.writePaths.push(path); - if (this.writePaths.length === 1) { - this.firstWriteStarted.resolve(); - await this.releaseFirstWrite.promise; - } - if (this.writePaths.length === 2) this.secondWriteStarted.resolve(); - this.writtenPaths.add(path); - return { ok: true as const, value: undefined }; - } -} - -class BlockingAppendEnv extends NodeExecutionEnv { - readonly appendStarted = createDeferred(); - readonly releaseAppend = createDeferred(); - - override async appendFile(path: string, content: string | Uint8Array) { - this.appendStarted.resolve(); - await this.releaseAppend.promise; - return super.appendFile(path, content); - } -} - -interface SessionBackendReadCounter { - openCount: number; - readHeadCount: number; - readEntriesCount: number; - readPathCount: number; - forkSelections: SessionForkSelection[]; -} - -function createCountingInMemorySessionBackend(): { - backend: Pick; - counter: SessionBackendReadCounter; -} { - const source = new InMemorySessionBackend(); - const counter: SessionBackendReadCounter = { - openCount: 0, - readHeadCount: 0, - readEntriesCount: 0, - readPathCount: 0, - forkSelections: [], - }; - const countReads = (storage: SessionStorage): SessionStorage => ({ - metadata: storage.metadata, - readHead: () => { - counter.readHeadCount += 1; - return storage.readHead(); - }, - readEntry: (id) => storage.readEntry(id), - readEntries: (options) => { - counter.readEntriesCount += 1; - return storage.readEntries(options); - }, - appendEntry: (entry) => storage.appendEntry(entry), - findEntriesOnBranch: (query) => storage.findEntriesOnBranch(query), - readPathToRootOrCompaction: (leafId) => { - counter.readPathCount += 1; - return storage.readPathToRootOrCompaction(leafId); - }, - getLabel: (id) => storage.getLabel(id), - getName: () => storage.getName(), - getStats: () => storage.getStats(), - }); - return { - counter, - backend: { - create: (options) => source.create(options), - async open(metadata) { - counter.openCount += 1; - return countReads(await source.open(metadata)); - }, - list: () => source.list(), - delete: (metadata) => source.delete(metadata), - fork: (metadata, options, selection) => { - counter.forkSelections.push(selection); - return source.fork(metadata, options, selection); - }, - [Symbol.asyncDispose]: () => source[Symbol.asyncDispose](), - }, - }; -} - -describe("InMemorySessionBackend", () => { - it("opens, deletes, and forks by metadata", async () => { - const repo = new InMemorySessionRepository(); - const session = await repo.create({ id: "session-1" }); - const metadata = await session.getMetadata(); - const user1 = await session.appendMessage(createUserMessage("one")); - const assistant1 = await session.appendMessage(createAssistantMessage("two")); - const user2 = await session.appendMessage(createUserMessage("three")); - await expect((await repo.open(metadata)).getMetadata()).resolves.toEqual(metadata); - expect((await repo.list()).map((info) => info.id)).toEqual(["session-1"]); - const fork = await repo.fork(metadata, { entryId: user2, id: "session-2" }); - expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]); - const fullFork = await repo.fork(metadata, { id: "session-3" }); - const throughFork = await repo.fork(metadata, { - entryId: assistant1, - position: "at", - id: "session-4", - }); - expect((await throughFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]); - await expect(repo.fork(metadata, { entryId: assistant1, id: "session-5" })).rejects.toMatchObject({ - code: "invalid_fork_target", - }); - await expect(repo.fork(metadata, { entryId: "missing", id: "session-6" })).rejects.toMatchObject({ - code: "invalid_fork_target", - }); - expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]); - await repo.delete(metadata); - await expect(repo.open(metadata)).rejects.toThrow("Session not found: session-1"); - }); - - it("delegates full-session fork selection without opening the source", async () => { - const { backend, counter } = createCountingInMemorySessionBackend(); - const source = await createSession(await backend.create({ id: "session-1" })); - await source.appendMessage(createUserMessage("one")); - - const fork = await createSession( - await backend.fork(await source.getMetadata(), { id: "session-2" }, { kind: "all" }), - ); - - expect((await fork.getEntries()).map((entry) => entry.id)).toHaveLength(1); - expect(counter).toMatchObject({ - openCount: 0, - readHeadCount: 0, - readEntriesCount: 0, - readPathCount: 0, - forkSelections: [{ kind: "all" }], - }); - }); - - it("retains the opened aggregate instead of reloading for scoped reads", async () => { - const { backend, counter } = createCountingInMemorySessionBackend(); - const session = await createSession(await backend.create({ id: "session-1" })); - const entryId = await session.appendMessage(createUserMessage("one")); - - await session.getMetadata(); - await session.getLeafId(); - await session.getEntry(entryId); - expect(counter.openCount).toBe(0); - }); - - it("builds context from the branch storage without loading complete history", async () => { - const { backend, counter } = createCountingInMemorySessionBackend(); - const created = await createSession(await backend.create({ id: "session-1" })); - await created.appendMessage(createUserMessage("one")); - const opened = await createSession(await backend.open(await created.getMetadata())); - - await opened.buildContext(); - - expect(counter).toMatchObject({ openCount: 1, readHeadCount: 1, readEntriesCount: 0, readPathCount: 1 }); - }); - - it("rejects repository operations and session writes after disposal", async () => { - const repo = new InMemorySessionRepository(); - const session = await repo.create({ id: "session-1" }); - await repo[Symbol.asyncDispose](); - - await expect(repo.list()).rejects.toThrow("In-memory session repository is disposed"); - await expect(session.appendMessage(createUserMessage("late"))).rejects.toThrow( - "In-memory session repository is disposed", - ); - }); - - it("supports lexical ownership with await using", async () => { - let listDisposedRepository: (() => Promise) | undefined; - { - await using repository = new InMemorySessionRepository(); - listDisposedRepository = () => repository.list(); - await repository.create({ id: "session-1" }); - } - - await expect(listDisposedRepository!()).rejects.toThrow("In-memory session repository is disposed"); - }); -}); - -describe("JsonlSessionBackend", () => { - it.each(["create", "fork"] as const)( - "serializes conflicting create and %s destinations", - async (secondOperation) => { - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const root = createTempDir(); - const env = new BlockingCreateEnv({ cwd: root }); - const sourcePath = `${root}/source.jsonl`; - writeFileSync( - sourcePath, - `${JSON.stringify({ type: "session", version: 3, id: "source", timestamp: "2025-01-01T00:00:00.000Z", cwd: "/tmp/source" })}\n`, - ); - env.writtenPaths.add(sourcePath); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const options = { cwd: "/tmp/target", id: "shared-id" }; - const first = backend.create(options); - await env.firstWriteStarted.promise; - const second = - secondOperation === "create" - ? backend.create(options) - : backend.fork( - { - id: "source", - createdAt: "2025-01-01T00:00:00.000Z", - cwd: "/tmp/source", - path: sourcePath, - }, - options, - { kind: "all" }, - ); - const secondStartedBeforeFirstFinished = await settlesBeforeNextTurn(env.secondWriteStarted.promise); - - env.releaseFirstWrite.resolve(); - const results = await Promise.allSettled([first, second]); - - expect(secondStartedBeforeFirstFinished).toBe(false); - expect(env.writePaths).toHaveLength(1); - expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]); - expect(results[1]).toMatchObject({ - status: "rejected", - reason: { code: "invalid_session", message: expect.stringContaining("Session already exists") }, - }); - }, - ); - - it("encodes custom session IDs used in filenames", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const snapshot = await backend.create({ cwd: root, id: "../unsafe\\id" }); - - expect(snapshot.metadata.path).toContain("..%2Funsafe%5Cid.jsonl"); - expect(existsSync(snapshot.metadata.path)).toBe(true); - }); - - it("allows appends to different sessions to run concurrently", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - - const firstAppend = first.appendEntry(createEntry("first-entry")); - await firstBlock.started.promise; - const secondAppend = second.appendEntry(createEntry("second-entry")); - const secondStartedBeforeFirstFinished = await settlesBeforeNextTurn(secondBlock.started.promise); - - firstBlock.release.resolve(); - secondBlock.release.resolve(); - await Promise.all([firstAppend, secondAppend]); - expect(secondStartedBeforeFirstFinished).toBe(true); - }); - - it("caps concurrent operations across JSONL sessions at four by default", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const snapshots = []; - for (let index = 0; index < 6; index++) { - snapshots.push(await backend.create({ cwd: `/tmp/session-${index}`, id: `session-${index}` })); - } - const blocks = snapshots.map((snapshot) => env.blockAppend(snapshot.metadata.path)); - const appends = snapshots.map((snapshot, index) => snapshot.appendEntry(createEntry(`entry-${index}`))); - await Promise.all(blocks.slice(0, 4).map((block) => block.started.promise)); - const fifthStartedWithoutCapacity = await settlesBeforeNextTurn(blocks[4]!.started.promise); - const sixthStartedWithoutCapacity = await settlesBeforeNextTurn(blocks[5]!.started.promise); - - for (const block of blocks) block.release.resolve(); - await Promise.all(appends); - - expect(fifthStartedWithoutCapacity).toBe(false); - expect(sixthStartedWithoutCapacity).toBe(false); - }); - - it("allows overriding the JSONL concurrency limit", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root, maxConcurrentOperations: 1 }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - const firstAppend = first.appendEntry(createEntry("first-entry")); - await firstBlock.started.promise; - const secondAppend = second.appendEntry(createEntry("second-entry")); - const secondStartedBeforeFirstFinished = await settlesBeforeNextTurn(secondBlock.started.promise); - - firstBlock.release.resolve(); - secondBlock.release.resolve(); - await Promise.all([firstAppend, secondAppend]); - expect(secondStartedBeforeFirstFinished).toBe(false); - }); - - it.each([0, -1, 1.5, Number.POSITIVE_INFINITY])( - "rejects invalid JSONL concurrency limit %s", - (maxConcurrentOperations) => { - const root = createTempDir(); - expect( - () => - new JsonlSessionBackend({ - fs: new NodeExecutionEnv({ cwd: root }), - sessionsRoot: root, - maxConcurrentOperations, - }), - ).toThrow("maxConcurrentOperations must be a positive integer"); - }, - ); - - it("releases JSONL concurrency capacity after an operation fails", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ - fs: new NodeExecutionEnv({ cwd: root }), - sessionsRoot: root, - maxConcurrentOperations: 1, - }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const duplicate = createEntry("duplicate-entry"); - await first.appendEntry(duplicate); - - const failure = first.appendEntry(duplicate); - const nextSession = second.appendEntry(createEntry("next-entry")); - - await expect(failure).rejects.toThrow("Entry duplicate-entry already exists"); - await expect(nextSession).resolves.toBeUndefined(); - }); - - it("serializes appends to the same session", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const snapshot = await backend.create({ cwd: root, id: "session" }); - const block = env.blockAppend(snapshot.metadata.path); - - const firstAppend = snapshot.appendEntry(createEntry("first-entry")); - await block.started.promise; - const secondAppend = snapshot.appendEntry(createEntry("second-entry")); - await new Promise((resolve) => setImmediate(resolve)); - const appendCountWhileFirstWasBlocked = env.appendCounts.get(snapshot.metadata.path); - - block.release.resolve(); - await Promise.all([firstAppend, secondAppend]); - expect(appendCountWhileFirstWasBlocked).toBe(1); - expect((await (await backend.open(snapshot.metadata)).readEntries()).map((entry) => entry.id)).toEqual([ - "first-entry", - "second-entry", - ]); - }); - - it("uses listing as a barrier between accepted session operations", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - const listBlock = env.blockNextList(); - - const firstAppend = first.appendEntry(createEntry("first-entry")); - await firstBlock.started.promise; - const list = backend.list(); - const secondAppend = second.appendEntry(createEntry("second-entry")); - const listStartedBeforeFirstFinished = await settlesBeforeNextTurn(listBlock.started.promise); - - firstBlock.release.resolve(); - await firstAppend; - await listBlock.started.promise; - const secondStartedBeforeListFinished = await settlesBeforeNextTurn(secondBlock.started.promise); - listBlock.release.resolve(); - await list; - await secondBlock.started.promise; - secondBlock.release.resolve(); - await secondAppend; - - expect(listStartedBeforeFirstFinished).toBe(false); - expect(secondStartedBeforeListFinished).toBe(false); - }); - - it("waits for every accepted session operation during disposal", async () => { - const root = createTempDir(); - const env = new PerPathBlockingAppendEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "first" }); - const second = await backend.create({ cwd: "/tmp/second", id: "second" }); - const firstBlock = env.blockAppend(first.metadata.path); - const secondBlock = env.blockAppend(second.metadata.path); - const firstAppend = first.appendEntry(createEntry("first-entry")); - const secondAppend = second.appendEntry(createEntry("second-entry")); - await Promise.all([firstBlock.started.promise, secondBlock.started.promise]); - - let disposed = false; - const dispose = backend[Symbol.asyncDispose]().then(() => { - disposed = true; - }); - firstBlock.release.resolve(); - await firstAppend; - await new Promise((resolve) => setImmediate(resolve)); - const disposedWhileSecondWasBlocked = disposed; - secondBlock.release.resolve(); - await Promise.all([secondAppend, dispose]); - - expect(disposedWhileSecondWasBlocked).toBe(false); - expect(disposed).toBe(true); - }); - - it("waits for accepted appends before disposal and rejects later writes", async () => { - const root = createTempDir(); - const env = new BlockingAppendEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const append = session.appendMessage(createUserMessage("accepted")); - await env.appendStarted.promise; - let closed = false; - const dispose = repo[Symbol.asyncDispose]().then(() => { - closed = true; - }); - await Promise.resolve(); - expect(closed).toBe(false); - env.releaseAppend.resolve(); - await append; - await dispose; - await expect(session.appendMessage(createUserMessage("late"))).rejects.toThrow( - "JSONL session repository is disposed", - ); - }); - - it("parses once when opened and retains state across appends", async () => { - const root = createTempDir(); - const env = new CountingReadEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const created = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await created.getMetadata(); - await created.appendMessage(createUserMessage("initial")); - env.readCount = 0; - const opened = await repo.open(metadata); - for (let i = 0; i < 10; i++) await opened.appendMessage(createUserMessage(`message ${i}`)); - expect(env.readCount).toBe(1); - }); - - it("collects sessions below encoded cwd directories and lists by cwd", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const cwd = "/tmp/my-project"; - const otherCwd = "/tmp/other-project"; - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" }); - const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" }); - const metadata = await session.getMetadata(); - const otherMetadata = await otherSession.getMetadata(); - expect(metadata.path).toContain("--tmp-my-project--"); - expect(otherMetadata.path).toContain("--tmp-other-project--"); - expect(existsSync(metadata.path)).toBe(true); - expect((await repo.list({ cwd })).map((sessionMetadata) => sessionMetadata.id)).toEqual([metadata.id]); - expect((await repo.list()).map((sessionMetadata) => sessionMetadata.id).sort()).toEqual( - [metadata.id, otherMetadata.id].sort(), - ); - }); - - it("fails loudly when listing a malformed session file", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - writeFileSync(metadata.path, "not json\n"); - - await expect(repo.list()).rejects.toMatchObject({ code: "invalid_session" }); - }); - - it("rejects a missing active leaf when opened", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const storage = await backend.create({ cwd: root, id: "session-1" }); - await storage.appendEntry({ - type: "leaf", - id: "leaf", - parentId: null, - targetId: "missing", - timestamp: "2026-01-01T00:00:00.000Z", - }); - - await expect(repo.open(storage.metadata)).rejects.toMatchObject({ - code: "invalid_session", - message: "Entry missing not found", - }); - }); - - it("opens, deletes, and forks by metadata", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const source = await repo.create({ cwd: "/tmp/source", id: "source-session" }); - const sourceMetadata = await source.getMetadata(); - const user1 = await source.appendMessage(createUserMessage("one")); - const assistant1 = await source.appendMessage(createAssistantMessage("two")); - const user2 = await source.appendMessage(createUserMessage("three")); - await expect((await repo.open(sourceMetadata)).getMetadata()).resolves.toEqual(sourceMetadata); - const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session", entryId: user2 }); - const forkMetadata = await fork.getMetadata(); - expect(forkMetadata.cwd).toBe("/tmp/target"); - expect(forkMetadata.parentSessionPath).toBe(sourceMetadata.path); - expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]); - const fullFork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "full-fork-session" }); - expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]); - await repo.delete(sourceMetadata); - expect(existsSync(sourceMetadata.path)).toBe(false); - await expect(repo.open(sourceMetadata)).rejects.toThrow("Session not found"); - }); - - it("persists header metadata through create, list, and fork", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const source = await repo.create({ - cwd: "/tmp/source", - id: "source-session", - metadata: { profile: "reviewer" }, - }); - const sourceMetadata = await source.getMetadata(); - expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" }); - expect((await repo.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([ - { profile: "reviewer" }, - ]); - const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" }); - expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" }); - const overridden = await repo.fork(sourceMetadata, { - cwd: "/tmp/target", - id: "overridden-session", - metadata: { profile: "writer" }, - }); - expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" }); - }); -}); diff --git a/packages/agent/test/harness/session-backends.test.ts b/packages/agent/test/harness/session-backends.test.ts deleted file mode 100644 index 7ccc27ffefd..00000000000 --- a/packages/agent/test/harness/session-backends.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { - JsonlSessionBackend, - JsonlSessionRepository, - loadJsonlSessionMetadata, -} from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import type { Session } from "../../src/harness/session/session.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -async function appendUsageEntries(session: Session) { - const assistant = createAssistantMessage("reply"); - if (assistant.role !== "assistant") throw new Error("Expected assistant test message"); - assistant.usage = { - input: 10, - output: 20, - cacheRead: 30, - cacheWrite: 40, - totalTokens: 100, - cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, - }; - await session.appendMessage(assistant); - await session.appendCompaction("summary", undefined, 1234, undefined, undefined, { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, - }); - await session.moveTo(await session.getLeafId(), { - summary: "branch", - usage: { - input: 5, - output: 6, - cacheRead: 7, - cacheWrite: 8, - totalTokens: 26, - cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, - }, - }); -} - -describe("Session aggregate", () => { - it("repository disposal closes its owned storage", async () => { - let session: Session; - { - await using repository = new InMemorySessionRepository(); - session = await repository.create({ id: "session-1" }); - } - - await expect(session!.appendMessage(createUserMessage("late"))).rejects.toThrow( - "In-memory session repository is disposed", - ); - }); - - it("owns leaf navigation, labels, names, stats, and branch traversal", async () => { - const repo = new InMemorySessionRepository(); - const session = await repo.create({ id: "session-1" }); - const root = await session.appendMessage(createUserMessage("root")); - const child = await session.appendMessage(createAssistantMessage("child")); - await session.appendLabel(root, "checkpoint"); - await session.appendSessionName(" review\nname "); - - expect(await session.getLeafId()).not.toBeNull(); - expect(await session.getLabel(root)).toBe("checkpoint"); - expect(await session.getSessionName()).toBe("review name"); - expect((await session.getBranch(child)).map((entry) => entry.id)).toEqual([root, child]); - - await session.moveTo(root); - expect(await session.getLeafId()).toBe(root); - expect((await session.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: root }); - await expect(session.moveTo("missing")).rejects.toThrow("Entry missing not found"); - }); - - it("serializes concurrent appends into one parent chain", async () => { - const session = await new InMemorySessionRepository().create({}); - await Promise.all( - Array.from({ length: 20 }, (_, index) => session.appendMessage(createUserMessage(`message ${index}`))), - ); - const entries = await session.getEntries(); - expect(entries).toHaveLength(20); - for (let index = 0; index < entries.length; index++) { - expect(entries[index]!.parentId).toBe(index === 0 ? null : entries[index - 1]!.id); - } - }); - - it("includes assistant and summary usage in statistics", async () => { - const session = await new InMemorySessionRepository().create({}); - await appendUsageEntries(session); - expect(await session.getSessionStats()).toEqual({ - messageCount: 1, - cachedTokens: 40, - uncachedTokens: 68, - totalTokens: 136, - costTotal: 1.36, - }); - }); - - it("stops branch traversal at retained-tail compaction", async () => { - const session = await new InMemorySessionRepository().create({}); - await session.appendMessage(createUserMessage("root")); - const child = await session.appendMessage(createAssistantMessage("child")); - const compaction = await session.appendCompaction("summary", child, 1234, undefined, undefined, undefined, [ - createAssistantMessage("child"), - ]); - const tail = await session.appendMessage(createUserMessage("after")); - expect((await session.getBranch(tail)).map((entry) => entry.id)).toEqual([compaction, tail]); - }); -}); - -describe("JsonlSessionBackend", () => { - it("writes headers and entries and reopens the aggregate", async () => { - const root = createTempDir(); - const repo = new JsonlSessionRepository({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1", metadata: { profile: "reviewer" } }); - const metadata = await session.getMetadata(); - const entryId = await session.appendMessage(createUserMessage("one")); - const reopened = await repo.open(metadata); - - expect(existsSync(metadata.path)).toBe(true); - expect((await reopened.getEntries()).map((entry) => entry.id)).toEqual([entryId]); - expect((await loadJsonlSessionMetadata(new NodeExecutionEnv({ cwd: root }), metadata.path)).metadata).toEqual({ - profile: "reviewer", - }); - const lines = readFileSync(metadata.path, "utf8").trim().split("\n"); - expect(JSON.parse(lines[0]!)).toMatchObject({ type: "session", version: 3, id: "session-1" }); - expect(JSON.parse(lines[1]!)).toMatchObject({ id: entryId, type: "message" }); - }); - - it("fails loudly for malformed headers and entries", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - writeFileSync(metadata.path, "not json\n"); - await expect(repo.open(metadata)).rejects.toMatchObject({ code: "invalid_session" }); - - const header = { type: "session", version: 3, id: "session-1", timestamp: metadata.createdAt, cwd: root }; - writeFileSync(metadata.path, `${JSON.stringify(header)}\nnot json\n`); - await expect(repo.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); - }); - - it("enforces entry uniqueness and does not recreate deleted files", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const snapshot = await backend.create({ cwd: root, id: "session-1" }); - const entry = { - type: "message" as const, - id: "entry-1", - parentId: null, - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage("one"), - }; - await snapshot.appendEntry(entry); - await expect(snapshot.appendEntry(entry)).rejects.toThrow("Entry entry-1 already exists"); - await backend.delete(snapshot.metadata); - await expect(snapshot.appendEntry({ ...entry, id: "entry-2" })).rejects.toThrow("Session not found"); - expect(existsSync(snapshot.metadata.path)).toBe(false); - }); - - it("scopes entry uniqueness to the session path", async () => { - const root = createTempDir(); - const backend = new JsonlSessionBackend({ fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }); - const first = await backend.create({ cwd: "/tmp/first", id: "shared-session-id" }); - const second = await backend.create({ cwd: "/tmp/second", id: "shared-session-id" }); - const entry = { - type: "message" as const, - id: "shared-entry-id", - parentId: null, - timestamp: "2026-01-01T00:00:00.000Z", - message: createUserMessage("one"), - }; - - await first.appendEntry(entry); - await expect(second.appendEntry(entry)).resolves.toBeUndefined(); - }); - - it("rejects non-object header metadata", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new JsonlSessionRepository({ fs: env, sessionsRoot: root }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - const header = { - type: "session", - version: 3, - id: metadata.id, - timestamp: metadata.createdAt, - cwd: root, - metadata: "profile", - }; - writeFileSync(metadata.path, `${JSON.stringify(header)}\n`); - await expect(repo.open(metadata)).rejects.toThrow("session header metadata must be an object"); - }); -}); diff --git a/packages/agent/test/harness/session-test-utils.ts b/packages/agent/test/harness/session-test-utils.ts index 6c84cd14b82..5634abb9611 100644 --- a/packages/agent/test/harness/session-test-utils.ts +++ b/packages/agent/test/harness/session-test-utils.ts @@ -1,42 +1,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { AgentMessage } from "@cheetahbyte/pi-agent-core"; import { afterEach } from "vitest"; -import { InMemorySessionRepository } from "../../src/harness/session/memory-repo.ts"; -import type { Session } from "../../src/harness/session/session.ts"; - -export async function createInMemorySession(id?: string): Promise { - return new InMemorySessionRepository().create({ id }); -} - -export function createUserMessage(text: string): AgentMessage { - return { - role: "user", - content: [{ type: "text", text }], - timestamp: Date.now(), - }; -} - -export function createAssistantMessage(text: string): AgentMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; -} const tempDirs: string[] = []; @@ -50,8 +15,6 @@ export function createTempDir(): string { afterEach(() => { while (tempDirs.length > 0) { const dir = tempDirs.pop()!; - if (existsSync(dir)) { - rmSync(dir, { recursive: true, force: true }); - } + if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); } }); diff --git a/packages/agent/test/harness/session.test.ts b/packages/agent/test/harness/session.test.ts deleted file mode 100644 index f7ee674e9e9..00000000000 --- a/packages/agent/test/harness/session.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { JsonlSessionBackend } from "../../src/harness/session/jsonl-repo.ts"; -import { InMemorySessionBackend } from "../../src/harness/session/memory-repo.ts"; -import { - type ContextEntryTransform, - createSession, - type Session, - type SessionContextBuildOptions, -} from "../../src/harness/session/session.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; - -function getTextData(data: unknown): string { - if (typeof data !== "object" || data === null || !("text" in data)) { - return ""; - } - const value = (data as { text?: unknown }).text; - return typeof value === "string" ? value : ""; -} - -interface SessionFixture { - createSession(options?: SessionContextBuildOptions): Promise; - reloadSession(options?: SessionContextBuildOptions): Promise; -} - -async function runSessionSuite(name: string, createFixture: () => Promise, inspect?: () => void) { - describe(name, () => { - it("appends messages and builds context in order", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const context = await session.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]); - }); - - it("reads entries forward from the requested sequence", async () => { - const session = await (await createFixture()).createSession(); - const ids = [ - await session.appendMessage(createUserMessage("one")), - await session.appendMessage(createUserMessage("two")), - await session.appendMessage(createUserMessage("three")), - ]; - - expect((await session.getEntries({ afterEntrySeq: 0, limit: 2 })).map((entry) => entry.id)).toEqual( - ids.slice(0, 2), - ); - expect((await session.getEntries({ afterEntrySeq: 1, limit: 2 })).map((entry) => entry.id)).toEqual( - ids.slice(1), - ); - expect((await session.getEntries({ afterEntrySeq: 2 })).map((entry) => entry.id)).toEqual(ids.slice(2)); - }); - - it("tracks model and thinking level changes", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendModelChange("openai", "gpt-4.1"); - await session.appendThinkingLevelChange("high"); - const context = await session.buildContext(); - expect(context.thinkingLevel).toBe("high"); - expect(context.model).toEqual({ provider: "openai", modelId: "gpt-4.1" }); - }); - - it("supports branching by moving the leaf and appending a new branch", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - const assistant1 = await session.appendMessage(createAssistantMessage("two")); - await session.appendMessage(createUserMessage("three")); - await session.moveTo(user1); - const branched = await session.appendMessage(createAssistantMessage("branched")); - const branch = await session.getBranch(); - expect(branch.map((entry) => entry.id)).toEqual([user1, branched]); - expect(branch.map((entry) => entry.id)).not.toContain(assistant1); - const context = await session.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]); - }); - - it("supports moving the leaf to root", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.moveTo(null); - expect(await session.getLeafId()).toBeNull(); - expect((await session.buildContext()).messages).toEqual([]); - }); - - it("reconstructs compaction summaries in context", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - const user2 = await session.appendMessage(createUserMessage("three")); - await session.appendMessage(createAssistantMessage("four")); - await session.appendCompaction("summary", user2, 1234, undefined, undefined, undefined, [ - createUserMessage("three"), - createAssistantMessage("four"), - ]); - await session.appendMessage(createUserMessage("five")); - const context = await session.buildContext(); - expect(context.messages[0]?.role).toBe("compactionSummary"); - expect(context.messages).toHaveLength(4); - expect(context.messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - "assistant", - "user", - ]); - }); - - it("supports moving with branch summary entries in context", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - const summaryId = await session.moveTo(user1, { summary: "summary text" }); - expect(summaryId).toBeTruthy(); - const summaryEntry = await session.getEntry(summaryId!); - expect(summaryEntry).toMatchObject({ type: "branch_summary", parentId: user1, fromId: user1 }); - const context = await session.buildContext(); - expect(context.messages[1]?.role).toBe("branchSummary"); - }); - - it("persists compaction usage", async () => { - const session = await (await createFixture()).createSession(); - const firstKeptEntryId = await session.appendMessage(createUserMessage("one")); - const usage = { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, - }; - - const compactionId = await session.appendCompaction( - "summary", - firstKeptEntryId, - 1234, - undefined, - false, - usage, - ); - - const compactionEntry = await session.getEntry(compactionId); - expect(compactionEntry?.type === "compaction" ? compactionEntry.usage : undefined).toEqual(usage); - }); - - it("persists branch summary usage", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - const usage = { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, - }; - - const summaryId = await session.moveTo(user1, { summary: "summary text", usage }); - - const summaryEntry = await session.getEntry(summaryId!); - expect(summaryEntry?.type === "branch_summary" ? summaryEntry.usage : undefined).toEqual(usage); - }); - - it("supports custom message entries in context", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendCustomMessageEntry("custom", "hello", true, { ok: true }); - const context = await session.buildContext(); - expect(context.messages[1]?.role).toBe("custom"); - }); - - it("keeps custom entries in context entries but omits them from messages by default", async () => { - const session = await (await createFixture()).createSession(); - await session.appendMessage(createUserMessage("one")); - await session.appendCustomEntry("chat_message", { text: "hello" }); - const contextEntries = await session.buildContextEntries(); - const context = await session.buildContext(); - expect(contextEntries.map((entry) => entry.type)).toEqual(["message", "custom"]); - expect(context.messages).toHaveLength(1); - }); - - it("projects custom entries with configured custom-entry projectors", async () => { - const session = await (await createFixture()).createSession({ - entryProjectors: { - chat_message: (entry) => [createUserMessage(`chat: ${getTextData(entry.data)}`)], - }, - }); - await session.appendMessage(createUserMessage("one")); - await session.appendCustomEntry("chat_message", { text: "hello" }); - const context = await session.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]); - expect(context.messages[1]).toMatchObject({ content: [{ type: "text", text: "chat: hello" }] }); - }); - - it("applies context entry transforms after default compaction selection", async () => { - let observedFirstEntryType: string | undefined; - const dropCompaction: ContextEntryTransform = (entries) => { - observedFirstEntryType = entries[0]?.type; - return entries.filter((entry) => entry.type !== "compaction"); - }; - const session = await (await createFixture()).createSession({ entryTransforms: [dropCompaction] }); - await session.appendMessage(createUserMessage("one")); - const kept = await session.appendMessage(createUserMessage("two")); - await session.appendCompaction("summary", kept, 1234); - await session.appendMessage(createUserMessage("three")); - const context = await session.buildContext(); - expect(observedFirstEntryType).toBe("compaction"); - expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]); - }); - - it("normalizes session names", async () => { - const session = await (await createFixture()).createSession(); - await session.appendSessionName(" hello\nworld\r\nagain "); - expect(await session.getSessionName()).toBe("hello world again"); - }); - - it("supports labels and session info entries without affecting context", async () => { - const session = await (await createFixture()).createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - await session.appendLabel(user1, "checkpoint"); - await session.appendSessionName("name"); - const entries = await session.getEntries(); - expect(entries.some((entry) => entry.type === "label")).toBe(true); - expect(entries.some((entry) => entry.type === "session_info")).toBe(true); - expect(await session.getLabel(user1)).toBe("checkpoint"); - expect(await session.getSessionName()).toBe("name"); - expect((await session.buildContext()).messages).toHaveLength(1); - }); - - it("rejects labels for missing entries", async () => { - const session = await (await createFixture()).createSession(); - await expect(session.appendLabel("missing", "checkpoint")).rejects.toThrow("Entry missing not found"); - }); - - it("persists leaf changes and appended entries through the backend", async () => { - const fixture = await createFixture(); - const session = await fixture.createSession(); - const user1 = await session.appendMessage(createUserMessage("one")); - await session.appendMessage(createAssistantMessage("two")); - await session.appendLabel(user1, "checkpoint"); - await session.appendSessionName("name"); - await session.moveTo(user1); - await session.appendMessage(createAssistantMessage("branched")); - const session2 = await fixture.reloadSession(); - const context = await session2.buildContext(); - expect(context.messages.map((message) => message.role)).toEqual(["user", "assistant"]); - expect(await session2.getLabel(user1)).toBe("checkpoint"); - expect(await session2.getSessionName()).toBe("name"); - inspect?.(); - }); - }); -} - -runSessionSuite("Session with in-memory backend", async () => { - const backend = new InMemorySessionBackend(); - const created = await createSession(await backend.create({})); - const metadata = await created.getMetadata(); - return { - createSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - reloadSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - }; -}); - -let jsonlSessionPath = ""; -runSessionSuite( - "Session with JSONL backend", - async () => { - const dir = createTempDir(); - const env = new NodeExecutionEnv({ cwd: dir }); - const backend = new JsonlSessionBackend({ fs: env, sessionsRoot: join(dir, "sessions") }); - const created = await createSession(await backend.create({ cwd: dir, id: "session-1" })); - const metadata = await created.getMetadata(); - jsonlSessionPath = metadata.path; - return { - createSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - reloadSession: async (contextBuildOptions) => createSession(await backend.open(metadata), contextBuildOptions), - }; - }, - () => { - const lines = readFileSync(jsonlSessionPath, "utf8").trim().split("\n"); - expect(lines.length).toBeGreaterThan(1); - const header = JSON.parse(lines[0]!); - expect(header.type).toBe("session"); - expect(header.version).toBe(3); - const entries = lines.slice(1).map((line) => JSON.parse(line)); - expect(entries.some((entry) => entry.type === "leaf")).toBe(true); - for (const entry of entries) { - expect(entry.type).not.toBe("entry"); - expect(typeof entry.id).toBe("string"); - } - }, -); diff --git a/packages/agent/test/harness/session/context.test.ts b/packages/agent/test/harness/session/context.test.ts new file mode 100644 index 00000000000..849ebba89cd --- /dev/null +++ b/packages/agent/test/harness/session/context.test.ts @@ -0,0 +1,124 @@ +import type { AgentMessage } from "@cheetahbyte/pi-agent-core"; +import type { AssistantMessage } from "@cheetahbyte/pi-ai"; +import { describe, expect, it } from "vitest"; +import { buildSessionContext } from "../../../src/harness/session/context.ts"; +import type { Entry } from "../../../src/harness/session/types.ts"; + +function userMessage(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +function assistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + }; +} + +type EntryWithoutStorage = TEntry extends Entry + ? Omit + : never; + +function entry(value: EntryWithoutStorage, seq: number): TEntry { + return { ...value, seq, timestamp: seq } as unknown as TEntry; +} + +describe("v4 session context", () => { + it("starts at the latest compaction and materializes its retained tail", () => { + const entries: Entry[] = [ + entry({ type: "message", id: "old", parentId: null, message: userMessage("old") }, 1), + entry( + { + type: "compaction", + id: "compact", + parentId: "old", + summary: "summary", + retainedTail: [userMessage("retained"), assistantMessage("answer")], + tokensBefore: 100, + }, + 2, + ), + entry({ type: "model_change", id: "model", parentId: "compact", provider: "openai", modelId: "gpt-5" }, 3), + entry({ type: "thinking_level_change", id: "thinking", parentId: "model", thinkingLevel: "high" }, 4), + entry({ type: "message", id: "tail", parentId: "thinking", message: userMessage("tail") }, 5), + ]; + + const context = buildSessionContext(entries); + expect(context.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + "user", + ]); + expect(context.model).toEqual({ provider: "openai", modelId: "gpt-5" }); + expect(context.thinkingLevel).toBe("high"); + }); + + it("applies caller transforms after the compaction boundary", () => { + const entries: Entry[] = [ + entry({ type: "message", id: "old", parentId: null, message: userMessage("old") }, 1), + entry( + { + type: "compaction", + id: "compact", + parentId: "old", + summary: "summary", + retainedTail: [], + tokensBefore: 100, + }, + 2, + ), + entry( + { + type: "branch_summary", + id: "branch", + parentId: "compact", + fromId: "abandoned", + summary: "branch summary", + }, + 3, + ), + entry({ type: "message", id: "tail", parentId: "branch", message: userMessage("tail") }, 4), + ]; + + const context = buildSessionContext(entries, { + entryTransforms: [(contextEntries) => contextEntries.filter((candidate) => candidate.type !== "compaction")], + }); + expect(context.messages.map((message) => message.role)).toEqual(["branchSummary", "user"]); + }); + + it("projects custom entries and omits deferred assistant handles", () => { + const deferred: AssistantMessage = { + ...assistantMessage(""), + content: [], + stopReason: "deferred", + deferred: { provider: "openai", modelId: "gpt-5", api: "openai-responses", id: "response-1" }, + }; + const entries: Entry[] = [ + entry({ type: "message", id: "user", parentId: null, message: userMessage("hello") }, 1), + entry({ type: "message", id: "deferred", parentId: "user", message: deferred }, 2), + entry({ type: "custom", id: "custom", parentId: "deferred", customType: "note", data: "project me" }, 3), + ]; + + const context = buildSessionContext(entries, { + entryProjectors: { + note: (custom) => [userMessage(`note: ${String(custom.data)}`)], + }, + }); + expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]); + expect(context.messages[1]).toMatchObject({ content: [{ type: "text", text: "note: project me" }] }); + }); +}); diff --git a/packages/agent/test/harness/experimental/session/memory.test.ts b/packages/agent/test/harness/session/memory.test.ts similarity index 91% rename from packages/agent/test/harness/experimental/session/memory.test.ts rename to packages/agent/test/harness/session/memory.test.ts index ae280dbb250..87c13c58bca 100644 --- a/packages/agent/test/harness/experimental/session/memory.test.ts +++ b/packages/agent/test/harness/session/memory.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { InMemorySessionRepo, InMemorySessionStorage, Session } from "../../../../src/experimental.ts"; +import { InMemorySessionRepo, InMemorySessionStorage, Session } from "../../../src/harness/session/index.ts"; import { createSessionBackendConformance, type SessionBackendFixture, -} from "../../../../src/harness/experimental/session/testing/index.ts"; +} from "../../../src/harness/session/testing/index.ts"; const conformance = createSessionBackendConformance(() => Promise.resolve({ diff --git a/packages/agent/test/harness/sqlite-migrations.test.ts b/packages/agent/test/harness/sqlite-migrations.test.ts deleted file mode 100644 index 9056754bd79..00000000000 --- a/packages/agent/test/harness/sqlite-migrations.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { - applyMigrations, - createNodeSqliteFactory, - loadMigrations, - type SqliteDatabase, - type SqliteDatabaseFactory, - type SqliteRunResult, - type SqliteSessionMetadata, - SqliteSessionRepository, - type SqliteStatement, -} from "../../../storage/sqlite-node/src/index.ts"; -import { SqliteSessionConnection } from "../../../storage/sqlite-node/src/sqlite/storage/index.ts"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts"; - -function createTempDir(): string { - return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-")); -} - -class ThrowingStatement implements SqliteStatement { - private readonly onRun: () => Promise; - - constructor(onRun: () => Promise) { - this.onRun = onRun; - } - - async run(..._params: unknown[]): Promise { - return this.onRun(); - } - - async get(..._params: unknown[]): Promise { - return undefined; - } - - async all(..._params: unknown[]): Promise { - return []; - } -} - -class CountingDatabase implements SqliteDatabase { - closeCount = 0; - private readonly statementFactory: (sql: string) => SqliteStatement; - - constructor(statementFactory: (sql: string) => SqliteStatement) { - this.statementFactory = statementFactory; - } - - async exec(_sql: string): Promise {} - - prepare(sql: string): SqliteStatement { - return this.statementFactory(sql); - } - - async transaction(fn: () => Promise): Promise { - return fn(); - } - - async close(): Promise { - this.closeCount += 1; - } -} - -function createCloseCountingSqliteFactory(): { - sqlite: SqliteDatabaseFactory; - counts: { opens: number; closes: number }; -} { - const source = createNodeSqliteFactory(); - const counts = { opens: 0, closes: 0 }; - return { - counts, - sqlite: { - async open(path) { - const db = await source.open(path); - counts.opens += 1; - return { - exec: (sql) => db.exec(sql), - prepare: (sql) => db.prepare(sql), - transaction: (fn) => db.transaction(fn), - async close() { - counts.closes += 1; - await db.close(); - }, - }; - }, - }, - }; -} - -describe("SQLite migrations", () => { - it("applies file-based migrations and records them", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - await repo.create({ cwd: root, id: "session-1" }); - - const db = await sqlite.open(databasePath); - try { - const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>(); - expect(rows.map((row) => row.id)).toEqual(["001_initial.sql", "002_branch_tips.sql"]); - const tables = await db - .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name") - .all<{ name: string; sql: string | null }>(); - expect(tables.map((row) => row.name)).toEqual( - expect.arrayContaining([ - "migrations", - "sessions", - "session_entries", - "session_sequences", - "branch_entries", - "branch_tips", - "session_materialized", - "entry_materialized", - ]), - ); - const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); - expect(sessionColumns.map((column) => column.name)).toContain("active_leaf_id"); - const branchIndexes = await db - .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'branch_entries'") - .all<{ name: string }>(); - expect(branchIndexes.map((index) => index.name)).toContain("idx_branch_entries_session_branch_seq"); - expect(branchIndexes.map((index) => index.name)).not.toContain("idx_branch_entries_session_branch"); - for (const tableName of [ - "sessions", - "session_sequences", - "branch_entries", - "branch_tips", - "session_materialized", - "entry_materialized", - ]) { - const table = tables.find((row) => row.name === tableName); - expect(table?.sql).toContain("WITHOUT ROWID"); - } - } finally { - await db.close(); - } - }); - - it("clears legacy branch projections when adding explicit tips", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const db = await sqlite.open(databasePath); - try { - const initial = (await loadMigrations()).find((migration) => migration.id === "001_initial.sql"); - if (!initial) throw new Error("Missing initial SQLite migration"); - await db.exec(initial.sql); - await db.exec("CREATE TABLE migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); - await db - .prepare("INSERT INTO migrations (id, applied_at) VALUES (?, ?)") - .run(initial.id, "2026-01-01T00:00:00.000Z"); - await db - .prepare("INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq) VALUES (?, ?, ?, ?)") - .run("session-1", "legacy-branch", "entry-1", 1); - - await applyMigrations(db); - - expect(await db.prepare("SELECT entry_id FROM branch_entries").all<{ entry_id: string }>()).toEqual([]); - expect( - await db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'branch_tips'").get(), - ).toBeDefined(); - expect( - (await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>()).map((row) => row.id), - ).toEqual(["001_initial.sql", "002_branch_tips.sql"]); - } finally { - await db.close(); - } - }); - - it("persists session metadata through create, list, open, and fork", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const source = await repo.create({ - cwd: root, - id: "session-1", - metadata: { profile: "reviewer" }, - }); - const sourceMetadata = await source.getMetadata(); - expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" }); - expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]); - expect((await (await repo.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" }); - const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" }); - expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" }); - const overridden = await repo.fork(sourceMetadata, { - cwd: root, - id: "session-3", - metadata: { profile: "writer" }, - }); - expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" }); - }); - - it("rolls back the entire fork when copying an entry fails", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const source = await repo.create({ cwd: root, id: "source" }); - await source.appendMessage(createUserMessage("one")); - await source.appendMessage(createAssistantMessage("two")); - - const db = await sqlite.open(databasePath); - try { - await db.exec(` -CREATE TRIGGER fail_fork_entry BEFORE INSERT ON session_entries -WHEN new.session_id = 'fork' AND new.entry_seq = 2 -BEGIN - SELECT RAISE(ABORT, 'fail fork'); -END; -`); - } finally { - await db.close(); - } - - await expect(repo.fork(await source.getMetadata(), { cwd: root, id: "fork" })).rejects.toMatchObject({ - code: "storage", - }); - const inspection = await sqlite.open(databasePath); - try { - expect( - await inspection.prepare("SELECT id FROM sessions WHERE id = ?").get<{ id: string }>("fork"), - ).toBeUndefined(); - expect( - await inspection.prepare("SELECT id FROM session_entries WHERE session_id = ?").all<{ id: string }>("fork"), - ).toEqual([]); - } finally { - await inspection.close(); - } - }); - - it("materializes active leaf id in sessions transactionally", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const childId = await session.appendMessage(createAssistantMessage("child")); - await session.moveTo(rootId); - - const db = await sqlite.open(databasePath); - try { - const row = await db - .prepare("SELECT active_leaf_id FROM sessions WHERE id = ?") - .get<{ active_leaf_id: string | null }>("session-1"); - expect(row?.active_leaf_id).toBe(rootId); - const latestBranchRow = await db - .prepare( - "SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1", - ) - .get<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1"); - const latestSessionEntry = await db - .prepare("SELECT id, type FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1") - .get<{ id: string; type: string }>("session-1"); - expect(latestSessionEntry?.type).toBe("leaf"); - expect(latestBranchRow?.entry_id).toBe(latestSessionEntry?.id); - if (!latestBranchRow) throw new Error("Missing latest branch row"); - const branchTip = await db - .prepare("SELECT branch_id, tip_id FROM branch_tips WHERE session_id = ? AND branch_id = ?") - .get<{ branch_id: string; tip_id: string }>("session-1", latestBranchRow.branch_id); - expect(branchTip?.tip_id).toBe(latestSessionEntry?.id); - } finally { - await db.close(); - } - - const reopened = await repo.open(await session.getMetadata()); - expect(await reopened.getLeafId()).toBe(rootId); - expect(childId).not.toBe(rootId); - }); - - it("materializes a new branch when appending from a parent with an existing child", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const firstChildId = await session.appendMessage(createAssistantMessage("first child")); - await session.moveTo(rootId); - const secondChildId = await session.appendMessage(createAssistantMessage("second child")); - - const db = await sqlite.open(databasePath); - try { - const branchRows = await db - .prepare( - "SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY branch_id, entry_seq", - ) - .all<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1"); - const branchIds = [...new Set(branchRows.map((row) => row.branch_id))]; - expect(branchIds).toHaveLength(2); - expect(branchRows.filter((row) => row.entry_id === rootId)).toHaveLength(2); - expect(branchRows.filter((row) => row.entry_id === firstChildId)).toHaveLength(1); - expect(branchRows.filter((row) => row.entry_id === secondChildId)).toHaveLength(1); - const tips = await db - .prepare("SELECT branch_id, tip_id FROM branch_tips WHERE session_id = ? ORDER BY branch_id") - .all<{ branch_id: string; tip_id: string }>("session-1"); - expect(tips.map((tip) => tip.branch_id)).toEqual(branchIds.sort()); - expect(new Set(tips.map((tip) => tip.tip_id)).size).toBe(tips.length); - } finally { - await db.close(); - } - }); - - it("reopens using branch materialization and session summary state", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - await session.appendMessage(createAssistantMessage("first child")); - await session.appendSessionName(" Reopened Session "); - await session.moveTo(rootId); - await session.appendMessage(createAssistantMessage("branched child")); - - const reopened = await repo.open(await session.getMetadata()); - expect(await reopened.getSessionName()).toBe("Reopened Session"); - expect((await reopened.buildContext()).messages.map((message) => message.role)).toEqual(["user", "assistant"]); - expect((await reopened.buildContext()).messages.at(-1)).toMatchObject({ - content: [{ type: "text", text: "branched child" }], - }); - }); - - it("pages entries by entry_seq cursor", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const ids = [ - await session.appendMessage(createUserMessage("one")), - await session.appendMessage(createAssistantMessage("two")), - await session.appendMessage(createUserMessage("three")), - ]; - - expect((await session.getEntries({ limit: 2 })).map((entry) => entry.id)).toEqual(ids.slice(0, 2)); - expect((await session.getEntries({ afterEntrySeq: 1, limit: 2 })).map((entry) => entry.id)).toEqual(ids.slice(1)); - }); - - it("closes the database when create fails after openDatabase succeeds", async () => { - const root = createTempDir(); - const db = new CountingDatabase((sql) => { - if (sql.startsWith("INSERT INTO sessions")) { - return new ThrowingStatement(async () => { - throw new Error("insert failed"); - }); - } - return new ThrowingStatement(async () => ({ changes: 1 })); - }); - const sqlite: SqliteDatabaseFactory = { - open: async () => db, - }; - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); - - await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed"); - expect(db.closeCount).toBe(0); - await repo[Symbol.asyncDispose](); - expect(db.closeCount).toBe(1); - }); - - it("closes the database when open fails after openDatabase succeeds", async () => { - const root = createTempDir(); - const db = new CountingDatabase((sql) => { - if (sql.includes("FROM sessions WHERE id = ?")) { - return new ThrowingStatement(async () => ({ changes: 0 })); - } - return new ThrowingStatement(async () => ({ changes: 1 })); - }); - const sqlite: SqliteDatabaseFactory = { - open: async () => db, - }; - const env = new NodeExecutionEnv({ cwd: root }); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); - const metadata: SqliteSessionMetadata = { - id: "missing", - createdAt: new Date().toISOString(), - cwd: root, - path: join(root, "sessions.sqlite"), - }; - writeFileSync(metadata.path, ""); - - await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing"); - expect(db.closeCount).toBe(0); - await repo[Symbol.asyncDispose](); - expect(db.closeCount).toBe(1); - }); - - it("retains one connection for repeated session operations", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const { sqlite, counts } = createCloseCountingSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - - const session = await repo.create({ cwd: root, id: "session-1" }); - for (let i = 0; i < 10; i++) await session.appendMessage(createUserMessage(`message ${i}`)); - await session.getEntries(); - expect(counts).toEqual({ opens: 1, closes: 0 }); - await repo[Symbol.asyncDispose](); - expect(counts).toEqual({ opens: 1, closes: 1 }); - await repo[Symbol.asyncDispose](); - expect(counts).toEqual({ opens: 1, closes: 1 }); - }); - - it("shares one connection across source and fork until the repository is disposed", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const { sqlite, counts } = createCloseCountingSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const source = await repo.create({ cwd: root, id: "session-1" }); - - const fork = await repo.fork(await source.getMetadata(), { cwd: root, id: "session-2" }); - await fork.appendMessage(createUserMessage("fork")); - expect(counts).toEqual({ opens: 1, closes: 0 }); - await repo[Symbol.asyncDispose](); - expect(counts).toEqual({ opens: 1, closes: 1 }); - }); - - it("rejects a missing active leaf when opened", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("UPDATE sessions SET active_leaf_id = ? WHERE id = ?").run("missing", metadata.id); - } finally { - await db.close(); - } - - await expect(repo.open(metadata)).rejects.toMatchObject({ - code: "invalid_session", - message: "Entry missing not found", - }); - }); - - it("fails loudly when a stored entry is read and cannot be decoded", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const entryId = await session.appendMessage(createUserMessage("message")); - const metadata = await session.getMetadata(); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE session_entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", metadata.id, entryId); - } finally { - await db.close(); - } - - const reopened = await repo.open(metadata); - await expect(reopened.getEntries()).rejects.toMatchObject({ code: "invalid_entry" }); - }); - - it("does not publish connection state when an append transaction fails", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const sqlite = createNodeSqliteFactory(); - const db = await sqlite.open(databasePath); - await applyMigrations(db); - const storage = await SqliteSessionConnection.create(db, databasePath, { - cwd: root, - sessionId: "session-1", - }); - await db.exec(` - CREATE TEMP TRIGGER fail_branch_tip_insert - BEFORE INSERT ON branch_tips - BEGIN - SELECT RAISE(ABORT, 'branch insert failed'); - END; - `); - - const rootEntry = { - type: "message" as const, - id: "root", - parentId: null, - timestamp: new Date().toISOString(), - message: createUserMessage("root"), - }; - try { - await expect(storage.appendEntry(rootEntry)).rejects.toMatchObject({ code: "storage" }); - } finally { - await db.exec("DROP TRIGGER fail_branch_tip_insert"); - } - const sessionRow = await db - .prepare("SELECT active_leaf_id FROM sessions WHERE id = ?") - .get<{ active_leaf_id: string | null }>("session-1"); - expect(sessionRow?.active_leaf_id).toBeNull(); - expect(await storage.readEntries()).toEqual([]); - await expect( - storage.appendEntry({ - type: "leaf", - id: "leaf", - parentId: null, - timestamp: new Date().toISOString(), - targetId: rootEntry.id, - }), - ).rejects.toMatchObject({ code: "not_found" }); - expect(await storage.readEntries()).toEqual([]); - await storage.appendEntry(rootEntry); - expect(await storage.readEntries()).toEqual([rootEntry]); - await db.close(); - }); - - it("materializes session summary fields transactionally", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const userId = await session.appendMessage(createUserMessage("one")); - await session.appendThinkingLevelChange("high"); - await session.appendModelChange("anthropic", "claude-sonnet-4-5"); - const assistant = { - ...createAssistantMessage("two"), - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: { - input: 100, - output: 25, - cacheRead: 40, - cacheWrite: 10, - totalTokens: 175, - cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 }, - }, - }; - await session.appendMessage(assistant); - await session.appendCompaction("summary", userId, 200, undefined, false, { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, - }); - await session.moveTo(userId, { - summary: "branch summary", - usage: { - input: 5, - output: 6, - cacheRead: 7, - cacheWrite: 8, - totalTokens: 26, - cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, - }, - }); - await session.appendSessionName(" My Session "); - await session.appendLabel(userId, "checkpoint"); - - const db = await sqlite.open(databasePath); - try { - const row = await db.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?").get<{ - session_id: string; - payload: string; - }>("session-1"); - expect(row).toBeDefined(); - expect(row?.session_id).toBe("session-1"); - expect(JSON.parse(row?.payload ?? "null")).toMatchObject({ - name: "My Session", - messageCount: 2, - cachedTokens: 50, - uncachedTokens: 128, - totalTokens: 211, - costTotal: 0.73, - currentModel: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, - currentThinkingLevel: "high", - }); - const entryRows = await db - .prepare( - "SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type", - ) - .all<{ - session_id: string; - entry_seq: number; - type: string; - payload: string; - }>("session-1"); - expect( - entryRows.some((entryRow) => entryRow.type === "label" && JSON.parse(entryRow.payload).targetId === userId), - ).toBe(true); - expect(entryRows.some((entryRow) => entryRow.type === "thinking")).toBe(false); - expect(entryRows.some((entryRow) => entryRow.type === "model")).toBe(false); - } finally { - await db.close(); - } - }); -}); diff --git a/packages/agent/test/harness/tool-context.types.ts b/packages/agent/test/harness/tool-context.types.ts deleted file mode 100644 index 111019f07aa..00000000000 --- a/packages/agent/test/harness/tool-context.types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Api, Model, Models } from "@cheetahbyte/pi-ai"; -import { AgentHarness } from "../../src/harness/agent-harness.ts"; -import { createReadTool } from "../../src/harness/tools/read.ts"; -import type { ExecutionToolContext } from "../../src/harness/tools/tool-context.ts"; -import type { Session } from "../../src/harness/types.ts"; - -declare const models: Models; -declare const model: Model; -declare const session: Session; -declare const toolContext: ExecutionToolContext; - -const readTool = createReadTool(); - -new AgentHarness({ models, model, session, tools: [readTool], toolContext }); - -// @ts-expect-error Context-requiring tools must be paired with toolContext. -new AgentHarness({ models, model, session, tools: [readTool] }); diff --git a/packages/agent/test/scratch/simple.ts b/packages/agent/test/scratch/simple.ts deleted file mode 100644 index c04927f5572..00000000000 --- a/packages/agent/test/scratch/simple.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { homedir } from "node:os"; -import { join } from "node:path"; -import { createModels } from "@cheetahbyte/pi-ai"; -import { cloudflareAIGatewayProvider } from "@cheetahbyte/pi-ai/providers/cloudflare-ai-gateway"; -import { openaiProvider } from "@cheetahbyte/pi-ai/providers/openai"; -import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; -import { - AgentHarness, - createBashTool, - createEditTool, - createReadTool, - createWriteTool, - formatSkillsForSystemPrompt, - InMemorySessionRepository, - loadSourcedPromptTemplates, - loadSourcedSkills, - type PromptTemplate, - type Skill, -} from "../../src/index.ts"; - -type Source = { type: "project" | "user" | "path"; dir: string }; -type SourcedSkill = Skill & { source: Source }; -type SourcedPromptTemplate = PromptTemplate & { source: Source }; - -const env = new NodeExecutionEnv({ cwd: process.cwd() }); - -const source = (type: Source["type"], dir: string) => ({ path: dir, source: { type, dir } }); -const { skills: sourcedSkills } = await loadSourcedSkills( - env, - [ - source("project", join(env.cwd, ".pi/skills")), - source("user", join(homedir(), ".pi/agent/skills")), - source("path", join(env.cwd, "../../../pi-skills")), - ], - (skill, source) => ({ ...skill, source }), -); -const { promptTemplates: sourcedPromptTemplates } = await loadSourcedPromptTemplates( - env, - [source("project", join(env.cwd, ".pi/prompts")), source("user", join(homedir(), ".pi/agent/prompts"))], - (promptTemplate, source) => ({ ...promptTemplate, source }), -); - -const models = createModels(); -models.setProvider(openaiProvider()); -models.setProvider(cloudflareAIGatewayProvider()); -const model = models.getModel("openai", "gpt-5.5"); -// const model = models.getModel("cloudflare-ai-gateway", "claude-haiku-4-5"); -if (!model) { - console.log("Model not found"); - process.exit(-1); -} - -await using repository = new InMemorySessionRepository(); -const session = await repository.create({}); -const agent = new AgentHarness({ - session, - models, - model, - thinkingLevel: "low", - tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()], - toolContext: { env }, - systemPrompt: ({ resources }) => - [ - "You are a helpful assistant.", - formatSkillsForSystemPrompt(resources.skills ?? []), - `Current working directory: ${env.cwd}`, - ] - .filter((part) => part.length > 0) - .join("\n\n"), - resources: { - promptTemplates: sourcedPromptTemplates.map(({ promptTemplate }) => promptTemplate), - skills: sourcedSkills.map(({ skill }) => skill), - }, -}); - -const response = await agent.prompt( - "What skills do you have? Any duplicates? Also use bash to get the current date and time, then read README.md and tell me what this project is about.", -); -console.log(response); diff --git a/packages/agent/vitest.config.ts b/packages/agent/vitest.config.ts index ed0ab064ab1..6d87b21d8b9 100644 --- a/packages/agent/vitest.config.ts +++ b/packages/agent/vitest.config.ts @@ -15,9 +15,9 @@ export default defineConfig({ }, resolve: { alias: [ - { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, - { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, - { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, + { find: /^@cheetahbyte\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@cheetahbyte\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@cheetahbyte\/pi-ai\/compat$/, replacement: aiSrcCompat }, ], }, }); diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts index 8045bb1a628..fae3b926d2c 100644 --- a/packages/agent/vitest.harness.config.ts +++ b/packages/agent/vitest.harness.config.ts @@ -22,9 +22,9 @@ export default defineConfig({ }, resolve: { alias: [ - { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, - { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, - { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, + { find: /^@cheetahbyte\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@cheetahbyte\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@cheetahbyte\/pi-ai\/compat$/, replacement: aiSrcCompat }, ], }, }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6be731ffc6b..68a5adc8dae 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ ### Breaking Changes +- Renamed the exported `ModelsStreamTransforms` interface to `ModelsRequestTransforms` because its header transformation now applies to all authenticated provider requests. - Required dynamic model providers to accept a concrete `RefreshModelsContext.signal`; `Models.refresh()` remains unbounded when callers omit its optional signal. - Required provider login, API-key check/resolution, and OAuth refresh implementations to accept a concrete abort signal; public auth and credential operations remain unbounded when callers omit their optional signal. - Replaced raw `RefreshModelsContext.store` access with the read-only `context.stored` snapshot and generation-checked `context.publish()` transaction. @@ -69,6 +70,7 @@ ### Added +- Added deferred provider request contracts, durable response handles, authenticated fetch/cancel dispatch, and faux-provider support for pending, ready, failed, and cancelled responses ([#7339](https://github.com/earendil-works/pi/pull/7339) by [@davidbrai](https://github.com/davidbrai)). - Added Baseten as a built-in OpenAI-compatible provider with models.dev catalog generation and native `chat_template_args` reasoning controls. ### Changed @@ -77,6 +79,7 @@ ### Fixed +- Fixed GitHub Copilot Grok 4.5 requests to use the supported Responses API ([#7560](https://github.com/earendil-works/pi/issues/7560)). - Bounded OAuth token refreshes so stalled requests release the credential-store lock ([#7508](https://github.com/earendil-works/pi/issues/7508)). - Fixed tool argument validation to preserve values that already match an `anyOf`/`oneOf` union arm before attempting coercion, avoiding nullable unions converting `null` to another primitive value ([#7328](https://github.com/earendil-works/pi/issues/7328)). - Fixed cancellation of model catalog refreshes so callers stop waiting even when a custom provider ignores its abort signal ([#7027](https://github.com/earendil-works/pi/issues/7027)). @@ -84,6 +87,7 @@ - Fixed newer provider refreshes being blocked by or overwritten by an older stalled generation, including persisted catalog publication. - Updated GPT-5.6 Terra and Luna pricing across OpenAI and passthrough model catalogs. - Fixed Fireworks Kimi K3 models to use the OpenAI-compatible API with native reasoning-effort levels and deferred tools ([#7199](https://github.com/earendil-works/pi/issues/7199), [#7230](https://github.com/earendil-works/pi/pull/7230) by [@XBeg9](https://github.com/XBeg9)). +- Updated Groq's Qwen reasoning override for the replacement `qwen/qwen3.6-27b` model. ## [0.83.0] - 2026-07-29 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index de1998b20b5..9fba351aef9 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -840,7 +840,7 @@ function applyThinkingLevelMetadata(model: Model): void { if (isGoogleThinkingApi(model) && isGemma4Model(model.id)) { mergeThinkingLevelMap(model, { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" }); } - if (model.provider === "groq" && model.id === "qwen/qwen3-32b") { + if (model.provider === "groq" && model.id === "qwen/qwen3.6-27b") { mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null, high: "default" }); } if (model.provider === "openai-codex" && supportsOpenAiXhigh(model.id)) { @@ -1856,10 +1856,13 @@ async function loadModelsDevData(): Promise[]> { // Claude 4.x and 5.x models route to Anthropic Messages API const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId); - // gpt-5, oswe, and MAI-Code models are only served through the - // Copilot /responses endpoint. + // Grok 4.5, gpt-5, oswe, and MAI-Code models are only served through + // the Copilot /responses endpoint. const needsResponsesApi = - modelId.startsWith("gpt-5") || modelId.startsWith("oswe") || modelId.startsWith("mai-"); + modelId === "grok-4.5" || + modelId.startsWith("gpt-5") || + modelId.startsWith("oswe") || + modelId.startsWith("mai-"); const api: Api = isCopilotClaude ? "anthropic-messages" diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index b68c007ad8b..dfc5202c03e 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -20,15 +20,16 @@ import type { AssistantMessage, AssistantMessageEventStream, Context, + DeferredCancelOptions, DeferredFetchOptions, DeferredHandle, Model, ModelCostRates, ModelThinkingLevel, ProviderHeaders, + ProviderRequestOptions, ProviderStreams, SimpleStreamOptions, - StreamOptions, Usage, } from "./types.ts"; import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts"; @@ -74,14 +75,15 @@ export interface ModelsRefreshResult { errors: ReadonlyMap; } -export interface ModelsStreamTransforms { +export interface ModelsRequestTransforms { /** Transform fully assembled model/auth/request headers before provider dispatch. */ transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise; } -export type ModelsApiStreamOptions = ApiStreamOptions & ModelsStreamTransforms; -export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsStreamTransforms; -export type ModelsDeferredOptions = DeferredFetchOptions & ModelsStreamTransforms; +export type ModelsApiStreamOptions = ApiStreamOptions & ModelsRequestTransforms; +export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsRequestTransforms; +export type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms; +export type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms; /** * A provider is the concrete runtime unit. It owns id/name/base metadata, @@ -143,7 +145,7 @@ export interface Provider { handle: DeferredHandle, options?: DeferredFetchOptions, ): AssistantMessageEventStream; - cancelDeferred?(model: Model, handle: DeferredHandle, options?: StreamOptions): Promise; + cancelDeferred?(model: Model, handle: DeferredHandle, options?: DeferredCancelOptions): Promise; } /** @@ -212,8 +214,12 @@ export interface Models { streamSimple(model: Model, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream; completeSimple(model: Model, context: Context, options?: ModelsSimpleStreamOptions): Promise; - fetchDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise; - cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise; + fetchDeferred( + model: Model, + handle: DeferredHandle, + options?: ModelsDeferredFetchOptions, + ): Promise; + cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredCancelOptions): Promise; } export interface MutableModels extends Models { @@ -627,10 +633,13 @@ class ModelsImpl implements MutableModels { return provider; } - private async applyAuth( + private async applyAuth( model: Model, options: TOptions | undefined, - ): Promise<{ requestModel: Model; requestOptions: StreamOptions | undefined }> { + ): Promise<{ + requestModel: Model; + requestOptions: Omit & ProviderRequestOptions; + }> { this.requireProvider(model); const resolution = await this.getAuth(model, { apiKey: options?.apiKey, @@ -649,7 +658,8 @@ class ModelsImpl implements MutableModels { const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {}; - const requestOptions = { ...providerOptions, apiKey, headers, env } as StreamOptions; + const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit & + ProviderRequestOptions; return { requestModel, requestOptions }; } @@ -696,7 +706,7 @@ class ModelsImpl implements MutableModels { async fetchDeferred( model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions, + options?: ModelsDeferredFetchOptions, ): Promise { return lazyStream(model, async () => { const provider = this.requireProvider(model); @@ -708,7 +718,11 @@ class ModelsImpl implements MutableModels { }).result(); } - async cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise { + async cancelDeferred( + model: Model, + handle: DeferredHandle, + options?: ModelsDeferredCancelOptions, + ): Promise { const provider = this.requireProvider(model); if (!provider.cancelDeferred) { throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`); diff --git a/packages/ai/src/providers/faux.ts b/packages/ai/src/providers/faux.ts index aac6567e594..284a099b314 100644 --- a/packages/ai/src/providers/faux.ts +++ b/packages/ai/src/providers/faux.ts @@ -3,6 +3,7 @@ import type { AssistantMessage, AssistantMessageEventStream, Context, + DeferredCancelOptions, DeferredFetchOptions, DeferredHandle, ImageContent, @@ -606,12 +607,7 @@ export function createFauxCore(options: RegisterFauxProviderOptions) { ...submissionOptions } = entry.options ?? {}; try { - entry.final = await resolveResponse( - entry.step, - entry.context, - { ...submissionOptions, ...fetchOptions }, - entry.model, - ); + entry.final = await resolveResponse(entry.step, entry.context, submissionOptions, entry.model); } catch (error) { entry.final = createErrorMessage(error, api, provider, entry.model.id); } @@ -637,7 +633,7 @@ export function createFauxCore(options: RegisterFauxProviderOptions) { const cancelDeferred = async ( requestModel: Model, handle: DeferredHandle, - cancelOptions?: StreamOptions, + cancelOptions?: DeferredCancelOptions, ): Promise => { state.cancelledDeferred.push(structuredClone(handle)); const entry = deferredResponses.get(handle.id); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7c55761c1f4..8e1ca5124bd 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -114,17 +114,8 @@ export interface ProviderResponse { headers: Record; } -export interface StreamOptions { - temperature?: number; - /** - * Arbitrary sampling parameters merged into the request body as-is, after the named request - * fields, so keys here override them. Lets custom OpenAI-compatible servers (llama.cpp, vLLM, - * SGLang, ...) receive parameters pi does not model, e.g. `top_p`, `top_k`, `min_p`, - * `repetition_penalty`. Merged over `Model.samplingParams` per key. Only applied by - * OpenAI-compatible adapters (completions, responses, Azure responses); other APIs ignore it. - */ - samplingParams?: Record; - maxTokens?: number; +/** Authentication, HTTP transport, and lifecycle callbacks shared by provider requests. */ +export interface ProviderRequestOptions> { signal?: AbortSignal; apiKey?: string; /** @@ -134,31 +125,20 @@ export interface StreamOptions { */ fetch?: FetchFunction; /** - * Preferred transport for providers that support multiple transports. - * Providers that do not support this option ignore it. - */ - transport?: Transport; - /** - * Prompt cache retention preference. Providers map this to their supported values. - * Default: "short". - */ - cacheRetention?: CacheRetention; - /** - * Optional session identifier for providers that support session-based caching. - * Providers can use this to enable prompt caching, request routing, or other - * session-aware features. Ignored by providers that don't support it. + * Provider-scoped environment values. These take precedence over process.env for + * provider configuration such as regional settings, endpoint placeholders, and + * proxy variables. */ - sessionId?: string; + env?: ProviderEnv; /** * Optional callback for inspecting or replacing provider payloads before sending. * Return undefined to keep the payload unchanged. */ - onPayload?: (payload: unknown, model: Model) => unknown | undefined | Promise; + onPayload?: (payload: unknown, model: TModel) => unknown | undefined | Promise; /** - * Optional callback invoked after an HTTP response is received and before - * its body stream is consumed. + * Optional callback invoked after an HTTP response is received. */ - onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + onResponse?: (response: ProviderResponse, model: TModel) => void | Promise; /** * Optional custom HTTP headers to include in API requests. * Merged with provider defaults; caller values override default headers. @@ -173,12 +153,6 @@ export interface StreamOptions { * For example, OpenAI and Anthropic SDK clients default to 10 minutes. */ timeoutMs?: number; - /** - * WebSocket connect timeout in milliseconds for providers that support - * WebSocket transports. This covers the connection/open handshake only; - * stream idleness after connection uses timeoutMs. - */ - websocketConnectTimeoutMs?: number; /** * Maximum retry attempts for providers/SDKs that support client-side retries. * For example, OpenAI and Anthropic SDK clients default to 2. @@ -192,27 +166,67 @@ export interface StreamOptions { * Default: 60000 (60 seconds). Set to 0 to disable the cap. */ maxRetryDelayMs?: number; +} + +export interface StreamOptions extends ProviderRequestOptions> { + /** + * Optional callback invoked after an HTTP response is received and before + * its body stream is consumed. + */ + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + temperature?: number; + /** + * Arbitrary sampling parameters merged into the request body as-is, after the named request + * fields, so keys here override them. Lets custom OpenAI-compatible servers (llama.cpp, vLLM, + * SGLang, ...) receive parameters pi does not model, e.g. `top_p`, `top_k`, `min_p`, + * `repetition_penalty`. Merged over `Model.samplingParams` per key. Only applied by + * OpenAI-compatible adapters (completions, responses, Azure responses); other APIs ignore it. + */ + samplingParams?: Record; + maxTokens?: number; + /** + * Preferred transport for providers that support multiple transports. + * Providers that do not support this option ignore it. + */ + transport?: Transport; + /** + * Prompt cache retention preference. Providers map this to their supported values. + * Default: "short". + */ + cacheRetention?: CacheRetention; + /** + * Optional session identifier for providers that support session-based caching. + * Providers can use this to enable prompt caching, request routing, or other + * session-aware features. Ignored by providers that don't support it. + */ + sessionId?: string; + /** + * WebSocket connect timeout in milliseconds for providers that support + * WebSocket transports. This covers the connection/open handshake only; + * stream idleness after connection uses timeoutMs. + */ + websocketConnectTimeoutMs?: number; /** * Optional metadata to include in API requests. * Providers extract the fields they understand and ignore the rest. * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. */ metadata?: Record; - /** - * Provider-scoped environment values. These take precedence over process.env for - * provider configuration such as regional settings, endpoint placeholders, and - * proxy variables. - */ - env?: ProviderEnv; } export type ProviderStreamOptions = StreamOptions & Record; -export interface DeferredFetchOptions extends StreamOptions { - /** Maximum time in milliseconds to wait for a terminal response. Zero checks once. */ +export interface DeferredFetchOptions extends ProviderRequestOptions> { + /** + * Maximum provider long-poll duration in milliseconds. + * Defaults to 0, which performs one status check. + */ wait?: number; } +/** Request options for best-effort deferred-response cancellation. */ +export type DeferredCancelOptions = ProviderRequestOptions>; + /** * Maps known APIs to their full provider-specific stream option types. * Type-only imports from API implementation modules are erased at emit, so @@ -255,7 +269,7 @@ export interface ProviderStreams { handle: DeferredHandle, options?: DeferredFetchOptions, ): AssistantMessageEventStream; - cancelDeferred?(model: Model, handle: DeferredHandle, options?: StreamOptions): Promise; + cancelDeferred?(model: Model, handle: DeferredHandle, options?: DeferredCancelOptions): Promise; } /** @@ -272,47 +286,7 @@ export interface ProviderImages { ): Promise; } -export interface ImagesOptions { - signal?: AbortSignal; - apiKey?: string; - /** Optional fetch implementation for provider HTTP requests. Defaults to `globalThis.fetch`. */ - fetch?: FetchFunction; - /** - * Provider-scoped environment values. These take precedence over process.env for - * provider configuration such as endpoint placeholders and proxy variables. - */ - env?: ProviderEnv; - /** - * Optional callback for inspecting or replacing provider payloads before sending. - * Return undefined to keep the payload unchanged. - */ - onPayload?: (payload: unknown, model: ImagesModel) => unknown | undefined | Promise; - /** - * Optional callback invoked after an HTTP response is received. - */ - onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise; - /** - * Optional custom HTTP headers to include in API requests. - * Merged with provider defaults; can override default headers. - * A null value suppresses a provider/API default header with the same name. - */ - headers?: ProviderHeaders; - /** - * HTTP request timeout in milliseconds for providers/SDKs that support it. - */ - timeoutMs?: number; - /** - * Maximum retry attempts for providers/SDKs that support client-side retries. - */ - maxRetries?: number; - /** - * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. - * If the server's requested delay exceeds this value, the request fails immediately - * with an error containing the requested delay, allowing higher-level retry logic - * to handle it with user visibility. - * Default: 60000 (60 seconds). Set to 0 to disable the cap. - */ - maxRetryDelayMs?: number; +export interface ImagesOptions extends ProviderRequestOptions> { /** * Optional metadata to include in API requests. * Providers extract the fields they understand and ignore the rest. diff --git a/packages/ai/test/model-catalog-types.test.ts b/packages/ai/test/model-catalog-types.test.ts index b3c47658e62..0facda3dbc8 100644 --- a/packages/ai/test/model-catalog-types.test.ts +++ b/packages/ai/test/model-catalog-types.test.ts @@ -1,4 +1,5 @@ -import { expectTypeOf, it } from "vitest"; +import { expect, expectTypeOf, it } from "vitest"; +import { GITHUB_COPILOT_MODELS } from "../src/providers/github-copilot.models.ts"; import { XAI_MODELS } from "../src/providers/xai.models.ts"; it("derives model API, ID, and provider literals from grouped model data", () => { @@ -7,3 +8,8 @@ it("derives model API, ID, and provider literals from grouped model data", () => expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">(); expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-completions">(); }); + +it("routes GitHub Copilot Grok 4.5 through the Responses API", () => { + expectTypeOf(GITHUB_COPILOT_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">(); + expect(GITHUB_COPILOT_MODELS["grok-4.5"].api).toBe("openai-responses"); +}); diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index 8b2d038bcc4..ffbcf6af3fa 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -766,8 +766,9 @@ describe("Models runtime", () => { controller.abort(); await expect(auth).rejects.toMatchObject({ name: "AbortError" }); - expect(receivedSignal).not.toBe(controller.signal); + expect(receivedSignal).toBeInstanceOf(AbortSignal); expect(receivedSignal?.aborted).toBe(true); + expect(receivedSignal?.reason).toBe(controller.signal.reason); finishRefresh?.({ ...previous, access: "new", expires: Date.now() + 60_000 }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(await credentials.read("p1")).toEqual(previous); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 5f8125807a2..f21b748599e 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -195,8 +195,8 @@ describe("openai-completions tool_choice", () => { expect("strict" in (tool ?? {})).toBe(false); }); - it("maps groq qwen3 reasoning levels to default reasoning_effort", async () => { - const model = getModel("groq", "qwen/qwen3-32b")!; + it("maps Groq Qwen reasoning levels to default reasoning_effort", async () => { + const model = getModel("groq", "qwen/qwen3.6-27b")!; let payload: unknown; await streamSimple( diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index 7633cea2d5a..14f0b20a09c 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -11,7 +11,15 @@ import { cloudflareAIGatewayProvider } from "../src/providers/cloudflare-ai-gate import { cloudflareWorkersAIProvider } from "../src/providers/cloudflare-workers-ai.ts"; import { fauxAssistantMessage, fauxProvider } from "../src/providers/faux.ts"; import { googleVertexProvider } from "../src/providers/google-vertex.ts"; -import type { Api, Context, DeferredHandle, Model, ProviderStreams } from "../src/types.ts"; +import type { + Api, + Context, + DeferredCancelOptions, + DeferredFetchOptions, + DeferredHandle, + Model, + ProviderStreams, +} from "../src/types.ts"; import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; function fakeAuthContext(env: Record, files: string[] = []): AuthContext { @@ -415,6 +423,81 @@ describe("createProvider", () => { expect(capturedEnv).toEqual({ PROVIDER_ONLY: "provider", REQUEST_ONLY: "request", SHARED: "request" }); }); + it("applies resolved request options to deferred fetch and cancellation", async () => { + let fetchedModel: Model | undefined; + let fetchedOptions: DeferredFetchOptions | undefined; + let cancelledOptions: DeferredCancelOptions | undefined; + const deferredModel = { ...testModel("api-a", "model-a"), provider: "deferred-provider" }; + const streams = recordingStreams("deferred", []); + streams.fetchDeferred = (model, _handle, options) => { + fetchedModel = model; + fetchedOptions = options; + return streams.streamSimple(model, context); + }; + streams.cancelDeferred = async (_model, _handle, options) => { + cancelledOptions = options; + }; + const provider = createProvider({ + id: "deferred-provider", + auth: { + apiKey: { + name: "Test", + resolve: async () => ({ + auth: { + apiKey: "provider-key", + baseUrl: "https://resolved.test/v1", + headers: { Authorization: "Bearer provider", "X-Shared": "provider" }, + }, + env: { PROVIDER_ONLY: "provider", SHARED: "provider" }, + }), + }, + }, + models: [deferredModel], + api: streams, + }); + const models = createModels(); + models.setProvider(provider); + const handle: DeferredHandle = { + provider: deferredModel.provider, + modelId: deferredModel.id, + api: deferredModel.api, + id: "response-1", + }; + + await models.fetchDeferred(deferredModel, handle, { + wait: 50, + timeoutMs: 100, + apiKey: "request-key", + headers: { "X-Request": "request", "x-shared": "request" }, + env: { REQUEST_ONLY: "request", SHARED: "request" }, + transformHeaders: (headers) => ({ ...headers, "X-Transformed": "yes" }), + }); + await models.cancelDeferred(deferredModel, handle, { + timeoutMs: 200, + transformHeaders: (headers) => ({ ...headers, "X-Cancel": "yes" }), + }); + + expect(fetchedModel?.baseUrl).toBe("https://resolved.test/v1"); + expect(fetchedOptions).toMatchObject({ + wait: 50, + timeoutMs: 100, + apiKey: "request-key", + headers: { + Authorization: "Bearer provider", + "X-Request": "request", + "x-shared": "request", + "X-Transformed": "yes", + }, + env: { PROVIDER_ONLY: "provider", REQUEST_ONLY: "request", SHARED: "request" }, + }); + expect(cancelledOptions).toMatchObject({ + timeoutMs: 200, + apiKey: "provider-key", + headers: { Authorization: "Bearer provider", "X-Shared": "provider", "X-Cancel": "yes" }, + env: { PROVIDER_ONLY: "provider", SHARED: "provider" }, + }); + }); + it("produces a stream error for a model whose api has no implementation", async () => { const provider = createProvider({ id: "mixed", @@ -510,7 +593,7 @@ describe("fauxProvider", () => { }); if (!deferred.deferred) throw new Error("Faux response did not include a deferred handle"); - const pending = await models.fetchDeferred(model, deferred.deferred, { wait: 0 }); + const pending = await models.fetchDeferred(model, deferred.deferred); expect(pending.stopReason).toBe("deferred"); expect(pending.deferred).toEqual(deferred.deferred); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f03ce4decf..3e31ccce9db 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- Added an advisory post-edit syntax guardrail to the `edit` tool: after applying edits, the resulting file is checked with a lightweight, dependency-free scan (JSON parsing for `.json`; a comment/string-aware brace, paren, and bracket balance and unterminated-literal check for common C-like/JS/TS languages). A mismatch surfaces as a warning in the tool result and TUI display without failing or reverting the edit. +- Re-surface the latest compaction summary's "Constraints & Preferences" as a short reminder near the end of context every 15 turns after compaction, so hard constraints stay reliably visible instead of drifting into the middle of long sessions. + +### Changed + +- Changed `outline` and `symbol` to use bundled Tree-sitter parsers for JavaScript, TypeScript, Python, Go, Rust, Java, and C#, improving declaration and body-boundary accuracy for supported source files. + ## [0.83.0-cheetahbyte.3] - 2026-08-04 ### Breaking Changes @@ -86,6 +95,8 @@ ### Fixed +- Fixed project-level nested provider retry settings replacing unmodified global provider retry settings ([#7572](https://github.com/earendil-works/pi/issues/7572)). +- Fixed inherited GitHub Copilot Grok 4.5 requests to use the supported Responses API ([#7560](https://github.com/earendil-works/pi/issues/7560)). - Fixed fullscreen shutdown leaking terminal capability-query replies into the parent shell prompt. - Fixed bare exact `--model` IDs shared by multiple providers choosing the first catalog entry instead of the sole authenticated provider or a clear ambiguity error ([#7327](https://github.com/earendil-works/pi/issues/7327)). - Fixed standalone x64 binaries requiring Haswell-era AVX2/BMI2 instructions by compiling release executables against Bun's baseline runtime ([#7149](https://github.com/earendil-works/pi/issues/7149)). @@ -111,6 +122,8 @@ - Fixed concurrent in-memory credential mutations losing unrelated provider updates by serializing their read-modify-write sections. - Updated `undici` to 8.9.0 and the packaged `brace-expansion` to 5.0.9 to address GHSA-8xcm-r25x-g524, GHSA-4cwx-7wf7-3272, GHSA-m8rv-5g2x-5cg5, GHSA-jr45-8vmc-qm54, GHSA-v3r7-h72x-cjcm, and GHSA-rgw5-rvv9-x895. - Fixed GitHub Copilot compaction and branch summaries using the Individual endpoint instead of the credential-resolved Business or Enterprise endpoint ([#6768](https://github.com/earendil-works/pi/issues/6768)). +- Fixed extension model calls dropping credential-resolved endpoints when forwarding request authentication, including custom compaction with GitHub Copilot Business and Enterprise accounts ([#7579](https://github.com/earendil-works/pi/issues/7579)). +- Fixed fullscreen transcript navigation leaving no editor-accessible `Home`, `End`, `PageUp`, or `PageDown` variants by adding Ctrl-modified editor bindings ([#7574](https://github.com/earendil-works/pi/issues/7574)). ## [0.83.0] - 2026-07-29 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 8fa41074d42..7dc2ed68713 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -32,12 +32,12 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 | `tui.editor.cursorRight` | `right`, `ctrl+f` | Move cursor right | | `tui.editor.cursorWordLeft` | `alt+left`, `ctrl+left`, `alt+b` | Move cursor word left | | `tui.editor.cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right | -| `tui.editor.cursorLineStart` | `home`, `ctrl+a` | Move to line start | -| `tui.editor.cursorLineEnd` | `end`, `ctrl+e` | Move to line end | +| `tui.editor.cursorLineStart` | `home`, `ctrl+home`, `ctrl+a` | Move to line start | +| `tui.editor.cursorLineEnd` | `end`, `ctrl+end`, `ctrl+e` | Move to line end | | `tui.editor.jumpForward` | `ctrl+]` | Jump forward to character | | `tui.editor.jumpBackward` | `ctrl+alt+]` | Jump backward to character | -| `tui.editor.pageUp` | `pageUp` | Scroll up by page | -| `tui.editor.pageDown` | `pageDown` | Scroll down by page | +| `tui.editor.pageUp` | `pageUp`, `ctrl+pageUp` | Scroll up by page | +| `tui.editor.pageDown` | `pageDown`, `ctrl+pageDown` | Scroll down by page | ### TUI Editor Deletion @@ -82,6 +82,17 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 These actions apply when interactive mode uses `--ui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. +Fullscreen transcript bindings take precedence over editor bindings. The default unmodified navigation keys therefore control the transcript in fullscreen mode, while their `ctrl` variants continue to control the editor. Outside fullscreen mode, both variants control the editor. + +| Key | Default mode | Fullscreen mode | +|-----|--------------|-----------------| +| `home`, `end` | Editor | Transcript | +| `ctrl+home`, `ctrl+end` | Editor | Editor | +| `pageUp`, `pageDown` | Editor | Transcript | +| `ctrl+pageUp`, `ctrl+pageDown` | Editor | Editor | + +This routing remains configurable through the ordinary action bindings. For example, `"tui.altScreen.pageUp": "ctrl+pageUp"` makes `pageUp` control the editor and `ctrl+pageUp` control the transcript in fullscreen mode. Setting `"tui.altScreen.pageUp": []` disables that transcript shortcut entirely. User bindings replace the defaults for that action. + | Keybinding id | Default | Description | |--------|---------|-------------| | `tui.altScreen.pageUp` | `pageUp` | Scroll the transcript up by one page | diff --git a/packages/coding-agent/examples/extensions/custom-compaction.ts b/packages/coding-agent/examples/extensions/custom-compaction.ts index 913af5900bf..8a106d7c130 100644 --- a/packages/coding-agent/examples/extensions/custom-compaction.ts +++ b/packages/coding-agent/examples/extensions/custom-compaction.ts @@ -31,17 +31,6 @@ export default function (pi: ExtensionAPI) { return; } - // Resolve request auth for the summarization model - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); - if (!auth.ok) { - ctx.ui.notify(`Compaction auth failed: ${auth.error}`, "warning"); - return; - } - if (!auth.apiKey) { - ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning"); - return; - } - // Combine all messages for full summary const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; @@ -91,9 +80,6 @@ ${conversationText} model, { messages: summaryMessages }, { - apiKey: auth.apiKey, - headers: auth.headers, - env: auth.env, maxTokens: 8192, signal, cacheRetention: "none", diff --git a/packages/coding-agent/examples/extensions/handoff.ts b/packages/coding-agent/examples/extensions/handoff.ts index 03af00d44d5..63fd2077766 100644 --- a/packages/coding-agent/examples/extensions/handoff.ts +++ b/packages/coding-agent/examples/extensions/handoff.ts @@ -117,11 +117,6 @@ export default function (pi: ExtensionAPI) { loader.onAbort = () => done(null); const doGenerate = async () => { - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!); - if (!auth.ok || !auth.apiKey) { - throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error); - } - const userMessage: Message = { role: "user", content: [ @@ -137,9 +132,6 @@ export default function (pi: ExtensionAPI) { ctx.model!, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { - apiKey: auth.apiKey, - headers: auth.headers, - env: auth.env, signal: loader.signal, cacheRetention: "none", sessionId: uuidv7(), diff --git a/packages/coding-agent/examples/extensions/qna.ts b/packages/coding-agent/examples/extensions/qna.ts index 46be8e855f6..fc6b905ea40 100644 --- a/packages/coding-agent/examples/extensions/qna.ts +++ b/packages/coding-agent/examples/extensions/qna.ts @@ -77,10 +77,6 @@ export default function (pi: ExtensionAPI) { // Do the work const doExtract = async () => { - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!); - if (!auth.ok || !auth.apiKey) { - throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error); - } const userMessage: UserMessage = { role: "user", content: [{ type: "text", text: lastAssistantText! }], @@ -90,7 +86,7 @@ export default function (pi: ExtensionAPI) { const response = await ctx.modelRegistry.complete( ctx.model!, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, - { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal: loader.signal }, + { signal: loader.signal }, ); if (response.stopReason === "aborted") { diff --git a/packages/coding-agent/examples/extensions/summarize.ts b/packages/coding-agent/examples/extensions/summarize.ts index 5b7daf167e9..e9cfe0c1733 100644 --- a/packages/coding-agent/examples/extensions/summarize.ts +++ b/packages/coding-agent/examples/extensions/summarize.ts @@ -1,5 +1,4 @@ import { uuidv7 } from "@cheetahbyte/pi-ai"; -import { complete, getModel } from "@cheetahbyte/pi-ai/compat"; import type { ExtensionAPI, ExtensionCommandContext } from "@cheetahbyte/pi-coding-agent"; import { DynamicBorder, getMarkdownTheme } from "@cheetahbyte/pi-coding-agent"; import { Container, Markdown, matchesKey, Text } from "@cheetahbyte/pi-tui"; @@ -161,20 +160,13 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("Preparing summary...", "info"); } - const model = getModel("openai", "gpt-5.2"); - if (!model && ctx.hasUI) { - ctx.ui.notify("Model openai/gpt-5.2 not found", "warning"); - } - - const auth = model ? await ctx.modelRegistry.getApiKeyAndHeaders(model) : undefined; - if (auth && !auth.ok && ctx.hasUI) { - ctx.ui.notify(auth.error, "warning"); - } - if (auth?.ok && !auth.apiKey && ctx.hasUI) { - ctx.ui.notify("No API key for openai/gpt-5.2", "warning"); + const model = ctx.modelRegistry.find("openai", "gpt-5.2"); + if (!model) { + if (ctx.hasUI) ctx.ui.notify("Model openai/gpt-5.2 not found", "warning"); + return; } - - if (!model || !auth?.ok || !auth.apiKey) { + if (!ctx.modelRegistry.hasConfiguredAuth(model)) { + if (ctx.hasUI) ctx.ui.notify("No authentication configured for openai/gpt-5.2", "warning"); return; } @@ -186,13 +178,10 @@ export default function (pi: ExtensionAPI) { }, ]; - const response = await complete( + const response = await ctx.modelRegistry.complete( model, { messages: summaryMessages }, { - apiKey: auth.apiKey, - headers: auth.headers, - env: auth.env, reasoningEffort: "high", cacheRetention: "none", sessionId: uuidv7(), diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index a40f3e11925..922218fe9a2 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -523,6 +523,7 @@ "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.9.0", + "web-tree-sitter": "0.26.11", "yaml": "2.9.0" }, "optionalDependencies": { @@ -1771,6 +1772,12 @@ "node": ">= 8" } }, + "node_modules/web-tree-sitter": { + "version": "0.26.11", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.11.tgz", + "integrity": "sha512-Q5Dm3YTIXSXuH6FxX6RuzX2Qwpc4DPGiYMU87Wg5Z8OIStiQFiUex4zMDc0vBTw78EphaYJacncJghCHzbZptg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 28146c9cea5..5d9f781a147 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -28,6 +28,7 @@ "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.9.0", + "web-tree-sitter": "0.26.11", "yaml": "2.9.0" }, "optionalDependencies": { @@ -1761,6 +1762,12 @@ "node": ">= 8" } }, + "node_modules/web-tree-sitter": { + "version": "0.26.11", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.11.tgz", + "integrity": "sha512-Q5Dm3YTIXSXuH6FxX6RuzX2Qwpc4DPGiYMU87Wg5Z8OIStiQFiUex4zMDc0vBTw78EphaYJacncJghCHzbZptg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f0abe5aad8e..76ee0aa3c24 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -36,8 +36,8 @@ "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", - "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", - "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", + "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/ && node ./scripts/copy-tree-sitter-assets.mjs", + "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/ && shx cp -r dist/core/tools/tree-sitter dist/tree-sitter", "test": "vitest --run", "shrinkwrap": "node ../../scripts/generate-coding-agent-shrinkwrap.mjs", "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" @@ -62,6 +62,7 @@ "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.9.0", + "web-tree-sitter": "0.26.11", "yaml": "2.9.0" }, "overrides": { @@ -75,6 +76,14 @@ "@mariozechner/clipboard": "0.3.9" }, "devDependencies": { + "@lumis-sh/wasm-csharp": "0.26.1", + "@lumis-sh/wasm-go": "0.26.1", + "@lumis-sh/wasm-java": "0.26.1", + "@lumis-sh/wasm-javascript": "0.26.2", + "@lumis-sh/wasm-python": "0.26.2", + "@lumis-sh/wasm-rust": "0.26.3", + "@lumis-sh/wasm-tsx": "0.26.1", + "@lumis-sh/wasm-typescript": "0.26.1", "@types/cross-spawn": "6.0.6", "@types/diff": "7.0.2", "@types/hosted-git-info": "3.0.5", diff --git a/packages/coding-agent/scripts/copy-tree-sitter-assets.mjs b/packages/coding-agent/scripts/copy-tree-sitter-assets.mjs new file mode 100644 index 00000000000..f2da14049c8 --- /dev/null +++ b/packages/coding-agent/scripts/copy-tree-sitter-assets.mjs @@ -0,0 +1,29 @@ +import { copyFile, mkdir, readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageDir = dirname(scriptDir); +const require = createRequire(import.meta.url); +const targetDir = process.argv[2] ?? join(packageDir, "dist", "core", "tools", "tree-sitter"); +const manifestPath = join(packageDir, "src", "core", "tools", "tree-sitter-manifest.json"); +const queryDir = join(packageDir, "src", "core", "tools", "tree-sitter-queries"); +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + +function resolvePackageAsset(packageName, assetName) { + return require.resolve(`${packageName}/${assetName}`); +} + +await mkdir(join(targetDir, "grammars"), { recursive: true }); +await mkdir(join(targetDir, "queries"), { recursive: true }); +await copyFile(manifestPath, join(targetDir, "manifest.json")); +await copyFile( + resolvePackageAsset(manifest.runtime.packageName, manifest.runtime.wasm), + join(targetDir, manifest.runtime.wasm), +); + +for (const grammar of Object.values(manifest.grammars)) { + await copyFile(resolvePackageAsset(grammar.packageName, grammar.wasm), join(targetDir, "grammars", grammar.wasm)); + await copyFile(join(queryDir, grammar.query), join(targetDir, "queries", grammar.query)); +} diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index c320aeb5ad7..619158e47c6 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -463,6 +463,14 @@ export function getBundledInteractiveAssetPath(name: string): string { return join(getInteractiveAssetsDir(), name); } +/** Get the directory containing bundled Tree-sitter runtime, grammar, and query assets. */ +export function getTreeSitterAssetsDir(): string { + if (isBunBinary) { + return join(getPackageDir(), "tree-sitter"); + } + return join(getPackageDir(), "dist", "core", "tools", "tree-sitter"); +} + // ============================================================================= // App Config (from package.json piConfig) // ============================================================================= diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 253b2406dfc..dcc514f9b78 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -54,6 +54,7 @@ import { normalizeToolResultImages } from "../utils/tool-result-images.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { + buildConstraintsReminderMessage, type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, @@ -395,6 +396,7 @@ export class AgentSession { this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._installAgentToolHooks(); this._installAgentNextTurnRefresh(); + this._installConstraintsReminderTransform(); this._buildRuntime({ activeToolNames: this._initialActiveToolNames, @@ -555,6 +557,27 @@ export class AgentSession { }; } + /** + * Wrap `agent.transformContext` so a short reminder of the latest compaction + * summary's "Constraints & Preferences" is appended near the end of the + * outgoing message list every `CONSTRAINTS_REMINDER_INTERVAL_TURNS` turns. + * + * `transformContext` runs once per LLM call, on a local copy of the context + * that is never written back to `agent.state.messages` and never persisted + * via the session manager, so the reminder is rebuilt fresh each time and + * cannot itself accumulate as clutter. Chained after any previously + * installed `transformContext` (e.g. the extension "context" hook wired up + * in sdk.ts/harness.ts) so both still run. + */ + private _installConstraintsReminderTransform(): void { + const previousTransformContext = this.agent.transformContext; + this.agent.transformContext = async (messages, signal) => { + const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages; + const reminder = buildConstraintsReminderMessage(this.sessionManager.getBranch()); + return reminder ? [...transformed, reminder] : transformed; + }; + } + // ========================================================================= // Event Subscription // ========================================================================= diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index ef8ac699a34..1c4e8b458bc 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -335,7 +335,12 @@ function isTurnStartMessage(message: AgentMessage): boolean { return false; } -function isTurnStartEntry(entry: SessionEntry): boolean { +/** + * Whether an entry starts a new turn (a user-initiated message boundary). + * Exported for reuse by the constraints-reminder module, which needs to count + * turns elapsed since the last compaction on the current branch. + */ +export function isTurnStartEntry(entry: SessionEntry): boolean { if (entry.type === "compaction") { return false; } diff --git a/packages/coding-agent/src/core/compaction/constraints-reminder.ts b/packages/coding-agent/src/core/compaction/constraints-reminder.ts new file mode 100644 index 00000000000..37ebe9d9865 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/constraints-reminder.ts @@ -0,0 +1,132 @@ +/** + * Periodic re-surfacing of the most recent compaction summary's + * "Constraints & Preferences" section near the end of context. + * + * Once compaction runs, its structured summary sits near the front of what + * remains of the context. As the session continues, new messages pile up + * after it and the summary drifts toward the middle of the context window — + * the "lost in the middle" zone where models retrieve information least + * reliably. This module builds a short, ephemeral reminder message that is + * appended near the end of the outgoing message list every few turns, so + * hard constraints stay reliably visible without re-summarizing or bloating + * the persisted session. + * + * The reminder is constructed fresh at context-assembly time only (see + * `AgentSession._installConstraintsReminderTransform`) and is never persisted + * as a session entry, so it cannot itself become stale or get duplicated on + * future rebuilds. + */ + +import { type CustomMessage, createCustomMessage } from "../messages.ts"; +import type { CompactionEntry, SessionEntry } from "../session-manager.ts"; +import { isTurnStartEntry } from "./compaction.ts"; + +/** + * How many turns must elapse since the last compaction before the + * constraints reminder is re-surfaced, and the recurring period after that + * (turn 15, 30, 45, ...). Picked from the middle of the 15-20 turn range + * suggested for periodic reinforcement: frequent enough that a long session + * doesn't drift far past the "lost in the middle" zone before being + * refreshed, infrequent enough to avoid adding noise on every turn. + */ +export const CONSTRAINTS_REMINDER_INTERVAL_TURNS = 15; + +/** `customType` used for the ephemeral reminder message. Not persisted anywhere. */ +export const CONSTRAINTS_REMINDER_CUSTOM_TYPE = "constraints-reminder"; + +const CONSTRAINTS_HEADING_RE = /^##+\s*Constraints\s*&\s*Preferences\s*$/im; +const NEXT_HEADING_RE = /^##+\s/m; + +/** + * Extract the bullet lines under "## Constraints & Preferences" from a + * compaction summary string. Returns undefined if the section is missing, + * has no bullets, or contains only a "(none)" placeholder. + */ +export function extractConstraintsBullets(summary: string): string[] | undefined { + const headingMatch = CONSTRAINTS_HEADING_RE.exec(summary); + if (!headingMatch) return undefined; + + const sectionStart = headingMatch.index + headingMatch[0].length; + const rest = summary.slice(sectionStart); + NEXT_HEADING_RE.lastIndex = 0; + const nextHeadingMatch = NEXT_HEADING_RE.exec(rest); + const sectionText = nextHeadingMatch ? rest.slice(0, nextHeadingMatch.index) : rest; + + const bullets = sectionText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("-")) + .map((line) => line.replace(/^-+\s*/, "").trim()) + .filter((line) => line.length > 0 && line.toLowerCase() !== "(none)"); + + return bullets.length > 0 ? bullets : undefined; +} + +/** Result of scanning a branch for turns elapsed since its most recent compaction. */ +export interface TurnsSinceCompaction { + /** The most recent compaction entry on the branch, or null if there is none. */ + compaction: CompactionEntry | null; + /** Number of turn-start entries strictly after the compaction entry. */ + turnsSinceCompaction: number; +} + +/** + * Scan a branch (root-to-leaf ordered entries, e.g. from `SessionManager.getBranch()`) + * for the most recent compaction entry and count turns that started after it. + */ +export function countTurnsSinceCompaction(branchEntries: SessionEntry[]): TurnsSinceCompaction { + let compactionIndex = -1; + for (let i = branchEntries.length - 1; i >= 0; i--) { + if (branchEntries[i].type === "compaction") { + compactionIndex = i; + break; + } + } + if (compactionIndex < 0) { + return { compaction: null, turnsSinceCompaction: 0 }; + } + + let turnsSinceCompaction = 0; + for (let i = compactionIndex + 1; i < branchEntries.length; i++) { + if (isTurnStartEntry(branchEntries[i])) turnsSinceCompaction++; + } + + return { compaction: branchEntries[compactionIndex] as CompactionEntry, turnsSinceCompaction }; +} + +function formatReminderText(bullets: string[]): string { + return [ + "Reminder — constraints and preferences from earlier in this session:", + ...bullets.map((bullet) => `- ${bullet}`), + ].join("\n"); +} + +/** + * Build the ephemeral constraints reminder message for the current turn, if one + * should be surfaced. Returns undefined when: + * - there is no compaction entry on the branch, + * - its summary has no non-empty "Constraints & Preferences" section, or + * - fewer than `CONSTRAINTS_REMINDER_INTERVAL_TURNS` turns (or a non-multiple + * of it) have elapsed since the compaction. + * + * `display: false` keeps this out of TUI rendering (it's a synthetic nudge, + * not something the user or assistant said); `convertToLlm` still turns it + * into a plain user-role message for the model. + */ +export function buildConstraintsReminderMessage(branchEntries: SessionEntry[]): CustomMessage | undefined { + const { compaction, turnsSinceCompaction } = countTurnsSinceCompaction(branchEntries); + if (!compaction) return undefined; + if (turnsSinceCompaction < CONSTRAINTS_REMINDER_INTERVAL_TURNS) return undefined; + if (turnsSinceCompaction % CONSTRAINTS_REMINDER_INTERVAL_TURNS !== 0) return undefined; + + const bullets = extractConstraintsBullets(compaction.summary); + if (!bullets) return undefined; + + return createCustomMessage( + CONSTRAINTS_REMINDER_CUSTOM_TYPE, + formatReminderText(bullets), + false, + undefined, + new Date().toISOString(), + ); +} diff --git a/packages/coding-agent/src/core/compaction/index.ts b/packages/coding-agent/src/core/compaction/index.ts index 7fae5f2c3f9..0ea1fcb73fe 100644 --- a/packages/coding-agent/src/core/compaction/index.ts +++ b/packages/coding-agent/src/core/compaction/index.ts @@ -4,4 +4,5 @@ export * from "./branch-summarization.ts"; export * from "./compaction.ts"; +export * from "./constraints-reminder.ts"; export * from "./utils.ts"; diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index ef8d4ad0da0..03dc6f52999 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -19,6 +19,7 @@ export type ResolvedRequestAuth = ok: true; apiKey?: string; headers?: ProviderHeaders; + baseUrl?: string; env?: Record; } | { ok: false; error: string }; @@ -74,6 +75,7 @@ export class ModelRegistry { ok: true, apiKey: resolution.auth.apiKey, headers: resolution.auth.headers, + ...(resolution.auth.baseUrl ? { baseUrl: resolution.auth.baseUrl } : {}), env: resolution.env, }; } catch (error) { diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index fe199c36227..4d0d3c798ac 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -14,22 +14,25 @@ import { type CredentialInfo, type CredentialStore, createModels, + type DeferredCancelOptions, type DeferredFetchOptions, type DeferredHandle, lazyStream, type Model, type Models, type ModelsApiStreamOptions, - type ModelsDeferredOptions, + type ModelsDeferredCancelOptions, + type ModelsDeferredFetchOptions, ModelsError, type ModelsRefreshOptions, type ModelsRefreshResult, + type ModelsRequestTransforms, type ModelsSimpleStreamOptions, type ModelsStore, - type ModelsStreamTransforms, type MutableModels, type Provider, type ProviderHeaders, + type ProviderRequestOptions, type SimpleStreamOptions, type StreamOptions, } from "@cheetahbyte/pi-ai"; @@ -559,10 +562,14 @@ export class ModelRuntime implements Models { return check ? { configured: true, source: "environment", label: check.source } : { configured: false }; } - private async prepareRequest( + private async prepareRequest( model: Model, - options: (StreamOptions & ModelsStreamTransforms) | undefined, - ): Promise<{ provider: Provider; model: Model; options: StreamOptions }> { + options: TOptions | undefined, + ): Promise<{ + provider: Provider; + model: Model; + options: Omit & ProviderRequestOptions; + }> { const provider = this.models.getProvider(model.provider); if (!provider) throw new ModelsError("provider", `Unknown provider: ${model.provider}`); const resolution = await this.getAuth(model, { @@ -572,7 +579,8 @@ export class ModelRuntime implements Models { }); if (!resolution) throw new ModelsError("auth", `Provider is not configured: ${model.provider}`); - const { transformHeaders, ...providerOptions } = options ?? {}; + const { transformHeaders, ...rawProviderOptions } = options ?? {}; + const providerOptions = rawProviderOptions as Omit & ProviderRequestOptions; let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers); if (transformHeaders) headers = await transformHeaders(headers ?? {}); const env = @@ -587,7 +595,7 @@ export class ModelRuntime implements Models { apiKey: providerOptions.apiKey ?? resolution.auth.apiKey, headers, env, - }, + } as Omit & ProviderRequestOptions, }; } @@ -599,7 +607,7 @@ export class ModelRuntime implements Models { return lazyStream(model, async () => { const prepared = await this.prepareRequest( model, - options as (StreamOptions & ModelsStreamTransforms) | undefined, + options as (StreamOptions & ModelsRequestTransforms) | undefined, ); return prepared.provider.stream( prepared.model as Model, @@ -631,7 +639,7 @@ export class ModelRuntime implements Models { async fetchDeferred( model: Model, handle: DeferredHandle, - options?: ModelsDeferredOptions, + options?: ModelsDeferredFetchOptions, ): Promise { return lazyStream(model, async () => { const prepared = await this.prepareRequest(model, options); @@ -642,12 +650,16 @@ export class ModelRuntime implements Models { }).result(); } - async cancelDeferred(model: Model, handle: DeferredHandle, options?: ModelsDeferredOptions): Promise { + async cancelDeferred( + model: Model, + handle: DeferredHandle, + options?: ModelsDeferredCancelOptions, + ): Promise { const prepared = await this.prepareRequest(model, options); if (!prepared.provider.cancelDeferred) { throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`); } - await prepared.provider.cancelDeferred(prepared.model, handle, prepared.options); + await prepared.provider.cancelDeferred(prepared.model, handle, prepared.options as DeferredCancelOptions); } login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise { diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8dadf1ec4b2..b59b431851c 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -23,8 +23,11 @@ import { createFindTool, createGrepTool, createLsTool, + createOutlineTool, createReadOnlyTools, createReadTool, + createRecallTool, + createSymbolTool, createWriteTool, type ToolName, withFileMutationQueue, @@ -123,6 +126,9 @@ export { createGrepTool, createFindTool, createLsTool, + createOutlineTool, + createRecallTool, + createSymbolTool, }; // Helper Functions diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4e955b15d5d..85209b4f937 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -133,37 +133,34 @@ export interface Settings { fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular UI mode } -/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ -function deepMergeSettings(base: Settings, overrides: Settings): Settings { - const result: Settings = { ...base }; +function isMergeableObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} - for (const key of Object.keys(overrides) as (keyof Settings)[]) { - const overrideValue = overrides[key]; - const baseValue = base[key]; +function deepMergeObjects(base: Record, overrides: Record): Record { + const result = { ...base }; + for (const key of Object.keys(overrides)) { + const overrideValue = overrides[key]; if (overrideValue === undefined) { continue; } - // For nested objects, merge recursively - if ( - typeof overrideValue === "object" && - overrideValue !== null && - !Array.isArray(overrideValue) && - typeof baseValue === "object" && - baseValue !== null && - !Array.isArray(baseValue) - ) { - (result as Record)[key] = { ...baseValue, ...overrideValue }; - } else { - // For primitives and arrays, override value wins - (result as Record)[key] = overrideValue; - } + const baseValue = base[key]; + result[key] = + isMergeableObject(baseValue) && isMergeableObject(overrideValue) + ? deepMergeObjects(baseValue, overrideValue) + : overrideValue; } return result; } +/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + return deepMergeObjects(base as Record, overrides as Record) as Settings; +} + function parseTimeoutSetting(value: unknown, settingName: string): number | undefined { const timeoutMs = parseHttpIdleTimeoutMs(value); if (timeoutMs !== undefined) { diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index b9fc4c56221..742c430c765 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -22,6 +22,7 @@ import { import { withFileMutationQueue } from "./file-mutation-queue.ts"; import { resolveToCwd } from "./path-utils.ts"; import { renderToolPath, str } from "./render-utils.ts"; +import { checkSyntax } from "./syntax-check.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; type EditPreview = EditDiffResult | EditDiffError; @@ -65,6 +66,8 @@ export interface EditToolDetails { patch: string; /** Line number of the first change in the new file (for editor navigation) */ firstChangedLine?: number; + /** Advisory warning if the resulting file may have a syntax issue; never blocks the edit */ + syntaxWarning?: string; } /** @@ -219,11 +222,18 @@ function formatEditResult( } const resultDiff = result.details?.diff; - if (resultDiff && resultDiff !== previewDiff) { - return renderDiff(resultDiff, { filePath: rawPath ?? undefined }); - } + const diffOutput = + resultDiff && resultDiff !== previewDiff ? renderDiff(resultDiff, { filePath: rawPath ?? undefined }) : undefined; + + const syntaxWarning = result.details?.syntaxWarning; + const warningOutput = syntaxWarning + ? theme.fg("warning", `[Warning: edited file may have a syntax issue — ${syntaxWarning}]`) + : undefined; - return undefined; + if (diffOutput && warningOutput) { + return `${diffOutput}\n${warningOutput}`; + } + return diffOutput ?? warningOutput; } function getEditHeaderBg( @@ -349,14 +359,23 @@ export function createEditToolDefinition( const diffResult = generateDiffString(baseContent, newContent); const patch = generateUnifiedPatch(path, baseContent, newContent); + const syntaxWarning = checkSyntax(path, newContent); + const text = syntaxWarning + ? `Successfully replaced ${edits.length} block(s) in ${path}.\n[Warning: edited file may have a syntax issue — ${syntaxWarning}]` + : `Successfully replaced ${edits.length} block(s) in ${path}.`; return { content: [ { type: "text", - text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + text, }, ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + details: { + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + syntaxWarning, + }, }; }); }, diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 65fbd409583..a2c5bad5b2d 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -197,17 +197,30 @@ export function createGrepToolDefinition( return path.basename(filePath); }; + // Bounded LRU: context-line formatting can touch hundreds of matched + // files, so cap how many files' full line arrays stay resident at + // once instead of retaining every matched file for the whole call. + const FILE_CACHE_LIMIT = 32; const fileCache = new Map(); const getFileLines = async (filePath: string): Promise => { - let lines = fileCache.get(filePath); - if (!lines) { - try { - const content = await ops.readFile(filePath); - lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); - } catch { - lines = []; - } - fileCache.set(filePath, lines); + const cached = fileCache.get(filePath); + if (cached) { + // Refresh recency. + fileCache.delete(filePath); + fileCache.set(filePath, cached); + return cached; + } + let lines: string[]; + try { + const content = await ops.readFile(filePath); + lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + } catch { + lines = []; + } + fileCache.set(filePath, lines); + if (fileCache.size > FILE_CACHE_LIMIT) { + const oldestKey = fileCache.keys().next().value; + if (oldestKey !== undefined) fileCache.delete(oldestKey); } return lines; }; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 19d722cfef9..c1db89f7950 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -42,6 +42,14 @@ export { type LsToolInput, type LsToolOptions, } from "./ls.ts"; +export { + createOutlineTool, + createOutlineToolDefinition, + type OutlineOperations, + type OutlineToolDetails, + type OutlineToolInput, + type OutlineToolOptions, +} from "./outline.ts"; export { createReadTool, createReadToolDefinition, @@ -50,6 +58,19 @@ export { type ReadToolInput, type ReadToolOptions, } from "./read.ts"; +export { + createRecallTool, + createRecallToolDefinition, + type RecallToolDetails, + type RecallToolInput, +} from "./recall.ts"; +export { + createSymbolTool, + createSymbolToolDefinition, + type SymbolToolDetails, + type SymbolToolInput, + type SymbolToolOptions, +} from "./symbol.ts"; export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -75,13 +96,27 @@ import { createEditTool, createEditToolDefinition, type EditToolOptions } from " import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createOutlineTool, createOutlineToolDefinition, type OutlineToolOptions } from "./outline.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; +import { createRecallTool, createRecallToolDefinition } from "./recall.ts"; +import { createSymbolTool, createSymbolToolDefinition, type SymbolToolOptions } from "./symbol.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls" | "symbol" | "recall" | "outline"; +export const allToolNames: Set = new Set([ + "read", + "bash", + "edit", + "write", + "grep", + "find", + "ls", + "symbol", + "recall", + "outline", +]); export interface ToolsOptions { read?: ReadToolOptions; @@ -91,6 +126,8 @@ export interface ToolsOptions { grep?: GrepToolOptions; find?: FindToolOptions; ls?: LsToolOptions; + symbol?: SymbolToolOptions; + outline?: OutlineToolOptions; } export function createToolDefinition(toolName: ToolName, cwd: string, options?: ToolsOptions): ToolDef { @@ -109,6 +146,12 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createFindToolDefinition(cwd, options?.find); case "ls": return createLsToolDefinition(cwd, options?.ls); + case "symbol": + return createSymbolToolDefinition(cwd, options?.symbol); + case "recall": + return createRecallToolDefinition(cwd); + case "outline": + return createOutlineToolDefinition(cwd, options?.outline); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -130,6 +173,12 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createFindTool(cwd, options?.find); case "ls": return createLsTool(cwd, options?.ls); + case "symbol": + return createSymbolTool(cwd, options?.symbol); + case "recall": + return createRecallTool(cwd); + case "outline": + return createOutlineTool(cwd, options?.outline); default: throw new Error(`Unknown tool name: ${toolName}`); } @@ -150,6 +199,9 @@ export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOption createGrepToolDefinition(cwd, options?.grep), createFindToolDefinition(cwd, options?.find), createLsToolDefinition(cwd, options?.ls), + createSymbolToolDefinition(cwd, options?.symbol), + createRecallToolDefinition(cwd), + createOutlineToolDefinition(cwd, options?.outline), ]; } @@ -162,6 +214,9 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R grep: createGrepToolDefinition(cwd, options?.grep), find: createFindToolDefinition(cwd, options?.find), ls: createLsToolDefinition(cwd, options?.ls), + symbol: createSymbolToolDefinition(cwd, options?.symbol), + recall: createRecallToolDefinition(cwd), + outline: createOutlineToolDefinition(cwd, options?.outline), }; } @@ -180,6 +235,9 @@ export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] createGrepTool(cwd, options?.grep), createFindTool(cwd, options?.find), createLsTool(cwd, options?.ls), + createSymbolTool(cwd, options?.symbol), + createRecallTool(cwd), + createOutlineTool(cwd, options?.outline), ]; } @@ -192,5 +250,8 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record; + +const DEFAULT_ENTRY_LIMIT = 500; +const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; +const OUTLINE_CACHE_MAX_ENTRIES = 20; + +export interface OutlineToolDetails { + truncation?: TruncationResult; + entryLimitReached?: number; +} + +export interface OutlineStat { + isDirectory: () => boolean; + size: number; + mtimeMs: number; +} + +export interface OutlineOperations { + access: (absolutePath: string) => Promise; + stat: (absolutePath: string) => Promise | OutlineStat; + readFile: (absolutePath: string) => Promise; +} + +const defaultOutlineOperations: OutlineOperations = { + access: (path) => fsAccess(path, constants.R_OK), + stat: (path) => fsStat(path), + readFile: (path) => fsReadFile(path), +}; + +export interface OutlineToolOptions { + operations?: OutlineOperations; + maxFileSizeBytes?: number; +} + +interface OutlineCacheEntry { + mtimeMs: number; + size: number; + source: string; + declarations: readonly SyntaxDeclaration[] | undefined; +} + +// Bounded LRU: Map iteration order tracks recency, so re-inserting on access/set and +// evicting the first key keeps memory from growing unboundedly across distinct files. +function cacheGet(cache: Map, key: string): OutlineCacheEntry | undefined { + const entry = cache.get(key); + if (entry === undefined) return undefined; + cache.delete(key); + cache.set(key, entry); + return entry; +} + +function cacheSet(cache: Map, key: string, entry: OutlineCacheEntry): void { + cache.delete(key); + cache.set(key, entry); + if (cache.size > OUTLINE_CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) cache.delete(oldestKey); + } +} + +interface OutlineEntry { + line: number; + indent: 0 | 1; + text: string; +} + +function signatureForDeclaration(source: string, declaration: SyntaxDeclaration): string { + const declarationSource = source.slice(declaration.startIndex, declaration.endIndex); + const line = + declarationSource.split(/\r?\n/).find((candidate) => !candidate.trimStart().startsWith("@")) ?? declarationSource; + return line + .trim() + .replace(/\s*(?:\{|:)\s*$/, "") + .replace(/;\s*$/, ""); +} + +function outlineEntries(source: string, declarations: readonly SyntaxDeclaration[]): OutlineEntry[] { + return declarations.flatMap((declaration) => { + if (declaration.parentKind !== undefined && !isTypeLikeDeclaration(declaration.parentKind)) return []; + return [ + { + line: declaration.startLine, + indent: declaration.parentKind === undefined ? 0 : 1, + text: signatureForDeclaration(source, declaration), + }, + ]; + }); +} + +function formatOutlineCall(args: { path?: string } | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.path), theme, cwd); + return `${theme.fg("toolTitle", theme.bold("outline"))} ${pathDisplay}`; +} + +function formatOutlineResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: OutlineToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + + const entryLimit = result.details?.entryLimitReached; + const truncation = result.details?.truncation; + if (entryLimit || truncation?.truncated) { + const warnings: string[] = []; + if (entryLimit) warnings.push(`${entryLimit} declarations limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createOutlineToolDefinition( + cwd: string, + options?: OutlineToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultOutlineOperations; + const maxFileSizeBytes = options?.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES; + const cache = new Map(); + return { + name: "outline", + label: "outline", + description: + "Show a compact outline of a single file's top-level declarations (classes, functions, interfaces, types, structs, methods, etc.) as signatures only, without bodies. " + + "Cheaper than reading a whole file to learn its shape; use it before read/grep to narrow in on the relevant part of a large or unfamiliar file. " + + `Supports JS/TS, Python, Go, Rust, and Java/C#. Output is truncated to ${DEFAULT_ENTRY_LIMIT} declarations or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: "Show a file's top-level declarations as signatures only", + promptGuidelines: ["Use outline to preview a large or unfamiliar file's structure before reading it in full."], + parameters: outlineSchema, + async execute(_toolCallId, { path }: { path: string }, signal?: AbortSignal, _onUpdate?, _ctx?) { + if (signal?.aborted) throw new Error("Operation aborted"); + const absolutePath = resolveToCwd(path, cwd); + try { + await ops.access(absolutePath); + } catch { + throw new Error(`Path not found: ${absolutePath}`); + } + const stat = await ops.stat(absolutePath); + if (stat.isDirectory()) throw new Error(`Not a file: ${absolutePath}. outline only supports a single file`); + if (stat.size > maxFileSizeBytes) { + return { + content: [ + { + type: "text", + text: `File is too large to parse (${formatSize(stat.size)} > ${formatSize(maxFileSizeBytes)} limit); use grep or read with an offset.`, + }, + ], + details: undefined, + }; + } + + const cached = cacheGet(cache, absolutePath); + let source: string; + let declarations: readonly SyntaxDeclaration[] | undefined; + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + source = cached.source; + declarations = cached.declarations; + } else { + source = (await ops.readFile(absolutePath)).toString("utf-8"); + if (signal?.aborted) throw new Error("Operation aborted"); + try { + declarations = await findDeclarations(absolutePath, source); + } catch { + return { + content: [{ type: "text", text: "Unable to parse this file (parser error); use read instead." }], + details: undefined, + }; + } + cacheSet(cache, absolutePath, { mtimeMs: stat.mtimeMs, size: stat.size, source, declarations }); + } + if (signal?.aborted) throw new Error("Operation aborted"); + if (declarations === undefined) { + return { + content: [{ type: "text", text: "No outline support for this file type; use read instead." }], + details: undefined, + }; + } + const entries = outlineEntries(source, declarations); + if (entries.length === 0) + return { content: [{ type: "text", text: "(no top-level declarations found)" }], details: undefined }; + + const entryLimitReached = entries.length > DEFAULT_ENTRY_LIMIT; + const limitedEntries = entryLimitReached ? entries.slice(0, DEFAULT_ENTRY_LIMIT) : entries; + const rawOutput = limitedEntries + .map((entry) => `${entry.indent === 1 ? " " : ""}${entry.line}: ${entry.text}`) + .join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: OutlineToolDetails = {}; + const notices: string[] = []; + if (entryLimitReached) { + notices.push(`${DEFAULT_ENTRY_LIMIT} declarations limit reached. Use grep to search for more`); + details.entryLimitReached = DEFAULT_ENTRY_LIMIT; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatOutlineCall(args, theme, context.cwd)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatOutlineResult(result, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createOutlineTool(cwd: string, options?: OutlineToolOptions): AgentTool { + return wrapToolDefinition(createOutlineToolDefinition(cwd, options)); +} diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index c3c948a62f3..5ab696f91a2 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -262,34 +262,48 @@ export function createReadToolDefinition( ]; } } else { - // Read text content. + // Read text content. Find line boundaries directly on the raw buffer + // instead of decoding the whole file to a string and splitting it into + // an array of every line - only the requested window is ever decoded. const buffer = await ops.readFile(absolutePath); - const textContent = buffer.toString("utf-8"); - const allLines = textContent.split("\n"); - const totalFileLines = allLines.length; + const lineStartOffsets: number[] = [0]; + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] === 0x0a) lineStartOffsets.push(i + 1); + } + const totalFileLines = lineStartOffsets.length; + // Byte range of line `index` (0-indexed), excluding its trailing newline - + // mirrors what `fullText.split("\n")[index]` would contain. + const lineByteRange = (index: number): [start: number, end: number] => [ + lineStartOffsets[index] ?? buffer.length, + index + 1 < lineStartOffsets.length ? lineStartOffsets[index + 1] - 1 : buffer.length, + ]; // Apply offset if specified. Convert from 1-indexed input to 0-indexed array access. const startLine = offset ? Math.max(0, offset - 1) : 0; const startLineDisplay = startLine + 1; // Check if offset is out of bounds. - if (startLine >= allLines.length) { - throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); + if (startLine >= totalFileLines) { + throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`); } let selectedContent: string; let userLimitedLines: number | undefined; // If limit is specified by the user, honor it first. Otherwise truncateHead decides. if (limit !== undefined) { - const endLine = Math.min(startLine + limit, allLines.length); - selectedContent = allLines.slice(startLine, endLine).join("\n"); + const endLine = Math.min(startLine + limit, totalFileLines); + const [rangeStart] = lineByteRange(startLine); + const [, rangeEnd] = lineByteRange(endLine - 1); + selectedContent = buffer.toString("utf-8", rangeStart, Math.max(rangeStart, rangeEnd)); userLimitedLines = endLine - startLine; } else { - selectedContent = allLines.slice(startLine).join("\n"); + const [rangeStart] = lineByteRange(startLine); + selectedContent = buffer.toString("utf-8", rangeStart, buffer.length); } // Apply truncation, respecting both line and byte limits. const truncation = truncateHead(selectedContent); let outputText: string; if (truncation.firstLineExceedsLimit) { // First line alone exceeds the byte limit. Point the model at a bash fallback. - const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); + const [firstLineStart, firstLineEnd] = lineByteRange(startLine); + const firstLineSize = formatSize(firstLineEnd - firstLineStart); outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; details = { truncation }; } else if (truncation.truncated) { @@ -303,9 +317,9 @@ export function createReadToolDefinition( outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; } details = { truncation }; - } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { + } else if (userLimitedLines !== undefined && startLine + userLimitedLines < totalFileLines) { // User-specified limit stopped early, but the file still has more content. - const remaining = allLines.length - (startLine + userLimitedLines); + const remaining = totalFileLines - (startLine + userLimitedLines); const nextOffset = startLine + userLimitedLines + 1; outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; } else { diff --git a/packages/coding-agent/src/core/tools/recall.ts b/packages/coding-agent/src/core/tools/recall.ts new file mode 100644 index 00000000000..f61c4e20e01 --- /dev/null +++ b/packages/coding-agent/src/core/tools/recall.ts @@ -0,0 +1,191 @@ +import type { AgentTool } from "@cheetahbyte/pi-agent-core"; +import { Text } from "@cheetahbyte/pi-tui"; +import { type Static, Type } from "typebox"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import type { SessionEntry } from "../session-manager.ts"; +import { getTextOutput } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; + +const recallSchema = Type.Object({ + query: Type.String({ description: "Case-insensitive substring to search for across the full session history" }), + limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 20)" })), +}); + +export type RecallToolInput = Static; + +const DEFAULT_LIMIT = 20; +/** Characters of context shown on each side of a match within an entry's JSON. */ +const SNIPPET_CONTEXT_CHARS = 150; + +export interface RecallToolDetails { + truncation?: TruncationResult; + matchLimitReached?: number; +} + +function describeEntryType(entry: SessionEntry): string { + if (entry.type === "message") { + return `message (${entry.message.role})`; + } + return entry.type; +} + +/** + * Build a snippet of the entry's JSON around the first occurrence of `query` + * (case-insensitive). Falls back to the start of the JSON if, for some reason, + * the match cannot be located (should not happen since callers already found it). + */ +function buildSnippet(entryJson: string, query: string): string { + const matchIndex = entryJson.toLowerCase().indexOf(query.toLowerCase()); + if (matchIndex < 0) { + const head = entryJson.slice(0, SNIPPET_CONTEXT_CHARS * 2); + return entryJson.length > head.length ? `${head}...` : head; + } + const start = Math.max(0, matchIndex - SNIPPET_CONTEXT_CHARS); + const end = Math.min(entryJson.length, matchIndex + query.length + SNIPPET_CONTEXT_CHARS); + let snippet = entryJson.slice(start, end); + if (start > 0) snippet = `...${snippet}`; + if (end < entryJson.length) snippet = `${snippet}...`; + return snippet; +} + +function formatRecallCall(args: { query?: string; limit?: number } | undefined, theme: Theme): string { + const query = typeof args?.query === "string" ? args.query : ""; + let text = `${theme.fg("toolTitle", theme.bold("recall"))} ${theme.fg("accent", `"${query}"`)}`; + if (args?.limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${args.limit})`); + } + return text; +} + +function formatRecallResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: RecallToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 15; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += theme.fg("muted", `\n... (${remaining} more lines)`); + } + } + + const matchLimit = result.details?.matchLimitReached; + const truncation = result.details?.truncation; + if (matchLimit || truncation?.truncated) { + const warnings: string[] = []; + if (matchLimit) warnings.push(`${matchLimit} matches limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createRecallToolDefinition( + _cwd: string, +): ToolDefinition { + return { + name: "recall", + label: "recall", + description: + "Search the full session history (including entries removed from the live context by compaction) for a case-insensitive substring match. Use this to recover exact error messages, code snippets, or earlier details that were summarized away. " + + `Returns up to ${DEFAULT_LIMIT} matches with surrounding context, or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is hit first.`, + promptSnippet: "Search full session history for details lost to compaction", + parameters: recallSchema, + async execute(_toolCallId, { query, limit }: RecallToolInput, signal?: AbortSignal, _onUpdate?, ctx?) { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + if (!ctx || !ctx.sessionManager) { + return { + content: [ + { + type: "text", + text: "Recall is unavailable: no active session. This tool requires a running agent session with a session manager.", + }, + ], + details: undefined, + }; + } + + const entries = ctx.sessionManager.getEntries(); + const lowerQuery = query.toLowerCase(); + const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + + const resultLines: string[] = []; + let matchCount = 0; + let matchLimitReached = false; + + for (const entry of entries) { + const entryJson = JSON.stringify(entry); + if (!entryJson.toLowerCase().includes(lowerQuery)) continue; + + matchCount++; + if (matchCount > effectiveLimit) { + matchLimitReached = true; + break; + } + + const snippet = buildSnippet(entryJson, query); + resultLines.push(`[${entry.timestamp}] ${describeEntryType(entry)}: ${snippet}`); + } + + if (resultLines.length === 0) { + return { + content: [{ type: "text", text: `No matches found for "${query}" in session history.` }], + details: undefined, + }; + } + + const rawOutput = resultLines.join("\n\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: RecallToolDetails = {}; + const notices: string[] = []; + if (matchLimitReached) { + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or narrow your query`, + ); + details.matchLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatRecallCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatRecallResult(result as any, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createRecallTool(cwd: string): AgentTool { + return wrapToolDefinition(createRecallToolDefinition(cwd)); +} diff --git a/packages/coding-agent/src/core/tools/symbol.ts b/packages/coding-agent/src/core/tools/symbol.ts new file mode 100644 index 00000000000..4cf7d904fc7 --- /dev/null +++ b/packages/coding-agent/src/core/tools/symbol.ts @@ -0,0 +1,325 @@ +import type { Dirent, Stats } from "node:fs"; +import { readdir as fsReaddir, readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; +import type { AgentTool } from "@cheetahbyte/pi-agent-core"; +import { Text } from "@cheetahbyte/pi-tui"; +import ignore from "ignore"; +import path from "path"; +import { type Static, Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { findDeclarations } from "./tree-sitter-navigation.ts"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; + +const symbolSchema = Type.Object({ + name: Type.String({ description: "Exact identifier name to search for (function, class, method, type, etc.)" }), + path: Type.Optional( + Type.String({ description: "File or directory to restrict the search to (default: current directory)" }), + ), + limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 20, max: 500)" })), +}); + +export type SymbolToolInput = Static; + +const DEFAULT_LIMIT = 20; +const MAX_BODY_LINES = 200; +const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; +const DEFAULT_MAX_FILES_SCANNED = 20000; +// The largest `limit` a caller may request. Bounds worst-case scan cost regardless of +// the requested limit; requests above it are capped and the cap is reported back. +const HARD_MATCH_CAP = 500; + +export interface SymbolToolDetails { + truncation?: TruncationResult; + matchLimitReached?: number; + requestedLimitCapped?: number; +} + +export interface SymbolToolOptions { + maxBodyLines?: number; + maxFileSizeBytes?: number; + maxFilesScanned?: number; +} + +type IgnoreMatcher = ReturnType; + +function toPosixPath(value: string): string { + return value.split(path.sep).join("/"); +} + +async function addGitignoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): Promise { + const relativeDir = path.relative(rootDir, dir); + const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; + const ignorePath = path.join(dir, ".gitignore"); + try { + const contents = await fsReadFile(ignorePath, "utf-8"); + const patterns: string[] = []; + for (const rawLine of contents.split(/\r?\n/)) { + const trimmed = rawLine.trim(); + if (!trimmed || (trimmed.startsWith("#") && !trimmed.startsWith("\\#"))) continue; + let pattern = rawLine; + let negated = false; + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + if (pattern.startsWith("/")) pattern = pattern.slice(1); + patterns.push(negated ? `!${prefix}${pattern}` : `${prefix}${pattern}`); + } + if (patterns.length > 0) ig.add(patterns); + } catch { + // Ignore missing or unreadable .gitignore files. + } +} + +async function walkDirectory( + dir: string, + rootDir: string, + ig: IgnoreMatcher, + out: string[], + maxFilesScanned: number, +): Promise { + if (out.length >= maxFilesScanned) return; + await addGitignoreRules(ig, dir, rootDir); + let entries: Dirent[]; + try { + entries = await fsReaddir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (out.length >= maxFilesScanned) return; + if (entry.name === ".git" || entry.name === "node_modules") continue; + const fullPath = path.join(dir, entry.name); + const relativePath = toPosixPath(path.relative(rootDir, fullPath)); + if (entry.isSymbolicLink()) { + // Never recurse into directory symlinks: skipping them avoids symlink-loop cycles entirely. + // Symlinks to regular files are safe to include since they can't recurse. + let stat: Stats; + try { + stat = await fsStat(fullPath); + } catch { + continue; + } + if (!stat.isFile()) continue; + if (relativePath && ig.ignores(relativePath)) continue; + out.push(fullPath); + continue; + } + if (entry.isDirectory()) { + if (relativePath && ig.ignores(`${relativePath}/`)) continue; + await walkDirectory(fullPath, rootDir, ig, out, maxFilesScanned); + } else if (entry.isFile()) { + if (relativePath && ig.ignores(relativePath)) continue; + out.push(fullPath); + } + } +} + +function limitBody(text: string, maxBodyLines: number): { text: string; truncated: boolean } { + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + if (lines.length <= maxBodyLines) return { text, truncated: false }; + return { text: lines.slice(0, maxBodyLines).join("\n"), truncated: true }; +} + +function formatSymbolCall( + args: { name: string; path?: string; limit?: number } | undefined, + theme: Theme, + cwd: string, +): string { + const name = str(args?.name); + const rawPath = str(args?.path); + const pathDisplay = rawPath !== null ? shortenPath(rawPath || ".") : null; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("symbol")) + + " " + + (name === null ? invalidArg : theme.fg("accent", name || "")) + + theme.fg("toolOutput", ` in ${pathDisplay === null ? invalidArg : pathDisplay || shortenPath(cwd)}`); + if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`); + return text; +} + +function formatSymbolResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: SymbolToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + const matchLimit = result.details?.matchLimitReached; + const truncation = result.details?.truncation; + if (matchLimit || truncation?.truncated) { + const warnings: string[] = []; + if (matchLimit) warnings.push(`${matchLimit} matches limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createSymbolToolDefinition( + cwd: string, + options?: SymbolToolOptions, +): ToolDefinition { + const maxBodyLines = options?.maxBodyLines ?? MAX_BODY_LINES; + const maxFileSizeBytes = options?.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES; + const maxFilesScanned = options?.maxFilesScanned ?? DEFAULT_MAX_FILES_SCANNED; + return { + name: "symbol", + label: "symbol", + description: + "Search for a symbol's declaration by exact name (function, class, method, type, struct, etc.) across source files. " + + "Returns the actual declaration with its body (where cheaply extractable), not just matching text lines. " + + "Respects .gitignore. Supports JS/TS, Python, Go, Rust, Java, and C#. " + + `Output is capped to ${DEFAULT_LIMIT} matches (max ${HARD_MATCH_CAP}), ${maxBodyLines} lines per body, or ${DEFAULT_MAX_BYTES / 1024}KB total (whichever is hit first).`, + promptSnippet: "Find a symbol's declaration by exact name, with its body", + parameters: symbolSchema, + async execute( + _toolCallId, + { name, path: searchPathArg, limit }: { name: string; path?: string; limit?: number }, + signal?: AbortSignal, + _onUpdate?, + _ctx?, + ) { + if (signal?.aborted) throw new Error("Operation aborted"); + if (!name || !name.trim()) throw new Error("name must not be empty"); + const normalizedName = name.normalize("NFC"); + const searchPath = resolveToCwd(searchPathArg || ".", cwd); + let rootStat: Stats; + try { + rootStat = await fsStat(searchPath); + } catch { + throw new Error(`Path not found: ${searchPath}`); + } + const requestedLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + const limitWasCapped = requestedLimit > HARD_MATCH_CAP; + const effectiveLimit = limitWasCapped ? HARD_MATCH_CAP : requestedLimit; + // Stop as soon as we've seen one more match than requested — that's all that's + // needed to know the result is truncated, so we never parse further files than necessary. + const matchCeiling = effectiveLimit + 1; + const files: string[] = []; + if (rootStat.isDirectory()) await walkDirectory(searchPath, searchPath, ignore(), files, maxFilesScanned); + else if (rootStat.isFile()) files.push(searchPath); + else throw new Error(`Not a file or directory: ${searchPath}`); + + interface FormattedMatch { + relPath: string; + line: number; + kind: string; + text: string; + truncated: boolean; + } + + const matches: FormattedMatch[] = []; + let totalMatchCount = 0; + fileLoop: for (const filePath of files) { + if (signal?.aborted) throw new Error("Operation aborted"); + let fileStat: Stats; + try { + fileStat = await fsStat(filePath); + } catch { + continue; + } + if (fileStat.size > maxFileSizeBytes) continue; + let source: string; + try { + source = await fsReadFile(filePath, "utf-8"); + } catch { + continue; + } + const declarations = await findDeclarations(filePath, source); + if (!declarations) continue; + for (const declaration of declarations) { + if (declaration.name !== normalizedName) continue; + totalMatchCount++; + if (matches.length < effectiveLimit) { + const body = limitBody(source.slice(declaration.startIndex, declaration.endIndex), maxBodyLines); + matches.push({ + relPath: rootStat.isDirectory() + ? toPosixPath(path.relative(searchPath, filePath)) + : path.basename(filePath), + line: declaration.startLine, + kind: declaration.kind, + text: body.text, + truncated: body.truncated, + }); + } + if (totalMatchCount >= matchCeiling) break fileLoop; + } + } + + if (matches.length === 0) + return { content: [{ type: "text", text: `No symbol named '${name}' found` }], details: undefined }; + const blocks = matches.map( + (match) => + `${match.relPath}:${match.line}: [${match.kind}]\n${match.text}${match.truncated ? "\n... [body truncated]" : ""}`, + ); + const truncation = truncateHead(blocks.join("\n\n"), { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: SymbolToolDetails = {}; + const notices: string[] = []; + if (limitWasCapped) { + notices.push( + `Requested limit ${requestedLimit} exceeds the maximum of ${HARD_MATCH_CAP}; capped to ${HARD_MATCH_CAP}`, + ); + details.requestedLimitCapped = HARD_MATCH_CAP; + } + if (totalMatchCount > effectiveLimit) { + details.matchLimitReached = effectiveLimit; + if (effectiveLimit < HARD_MATCH_CAP) { + const suggestedLimit = Math.min(effectiveLimit * 2, HARD_MATCH_CAP); + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${suggestedLimit} for more, or narrow path`, + ); + } else { + notices.push( + `${effectiveLimit} matches limit reached (maximum supported). Narrow path for more precise results`, + ); + } + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; + return { + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }; + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatSymbolCall(args, theme, context.cwd)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatSymbolResult(result, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createSymbolTool(cwd: string, options?: SymbolToolOptions): AgentTool { + return wrapToolDefinition(createSymbolToolDefinition(cwd, options)); +} diff --git a/packages/coding-agent/src/core/tools/syntax-check.ts b/packages/coding-agent/src/core/tools/syntax-check.ts new file mode 100644 index 00000000000..bd1f8bbac10 --- /dev/null +++ b/packages/coding-agent/src/core/tools/syntax-check.ts @@ -0,0 +1,298 @@ +/** + * Lightweight, dependency-free post-edit syntax sanity check. + * + * This is intentionally a heuristic, not a real parser: it exists to catch + * obviously broken edits (unbalanced brackets, an unterminated string left + * dangling at end of file, invalid JSON) and surface them as an advisory + * warning. It never blocks or fails an edit. Conservative by design: when a + * construct can't be classified with confidence (notably the JS/TS + * regex-literal-vs-division ambiguity), it prefers to stay silent rather than + * risk a false positive on well-formed code. + */ + +import { extname } from "path"; + +const JSON_EXTENSIONS = new Set([".json"]); + +// Extensions where `/` can start a regex literal and `${...}` template +// interpolation needs to be understood (otherwise a `}` closing an +// interpolation would look like a stray/unbalanced brace). +const JS_LIKE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]); + +// Other brace/paren/bracket-oriented languages that get the same balance and +// unterminated-literal scan, without the JS-specific regex/template handling. +const OTHER_BRACE_EXTENSIONS = new Set([ + ".go", + ".rs", + ".java", + ".c", + ".cpp", + ".cc", + ".h", + ".hpp", + ".hh", + ".cs", + ".swift", + ".kt", + ".kts", +]); + +const BRACE_EXTENSIONS = new Set([...JS_LIKE_EXTENSIONS, ...OTHER_BRACE_EXTENSIONS]); + +// Identifiers/keywords after which a following `/` is a value-producing +// position (division), not the start of a regex literal. +const VALUE_KEYWORDS = new Set(["this", "super", "true", "false", "null", "undefined"]); + +// Keywords after which a following `/` cannot be division, so it must be a +// regex literal (or, rarely, something else we don't need to model). +const REGEX_ALLOWED_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "throw", + "yield", + "case", + "do", + "else", + "await", + "extends", + "default", +]); + +type OpenBracket = "(" | "[" | "{" | "$"; + +const CLOSE_TO_OPEN: Record = { ")": "(", "]": "[", "}": "{" }; + +/** + * Look ahead from a `/` that might start a regex literal. Regex literals in + * JS/TS cannot contain an unescaped newline, so if no plausible closing `/` + * is found before the next newline (or end of file), this returns null and + * the caller falls back to treating the `/` as a plain operator character. + */ +function tryScanRegexLiteral(content: string, start: number): number | null { + let i = start + 1; + let inCharClass = false; + while (i < content.length) { + const c = content[i]; + if (c === "\\") { + i += 2; + continue; + } + if (c === "\n") return null; + if (inCharClass) { + if (c === "]") inCharClass = false; + i++; + continue; + } + if (c === "[") { + inCharClass = true; + i++; + continue; + } + if (c === "/") { + i++; + while (i < content.length && /[a-zA-Z]/.test(content[i])) i++; + return i; + } + i++; + } + return null; +} + +/** + * Scan brace/paren/bracket-oriented source for (a) unbalanced `()[]{}` and + * (b) a string/char/template literal still open at end of file. + * + * Comment- and string-aware, single pass. Strings/char literals are allowed + * to span multiple lines without being flagged (many languages in scope + * permit this - e.g. Rust plain strings, C# verbatim strings): the only + * unterminated-literal signal we trust is "still inside a literal at EOF", + * per the guardrail's conservative brief. + */ +function checkBraceBalance(content: string, isJsLike: boolean): string | undefined { + const stack: OpenBracket[] = []; + type State = "code" | "line-comment" | "block-comment" | "single" | "double" | "template"; + let state: State = "code"; + let regexAllowed = true; + + for (let i = 0; i < content.length; i++) { + const c = content[i]; + + if (state === "line-comment") { + if (c === "\n") state = "code"; + continue; + } + + if (state === "block-comment") { + if (c === "*" && content[i + 1] === "/") { + i++; + state = "code"; + } + continue; + } + + if (state === "single" || state === "double") { + if (c === "\\") { + i++; + continue; + } + if ((state === "single" && c === "'") || (state === "double" && c === '"')) { + state = "code"; + regexAllowed = false; + } + continue; + } + + if (state === "template") { + if (c === "\\") { + i++; + continue; + } + if (c === "`") { + state = "code"; + regexAllowed = false; + continue; + } + if (isJsLike && c === "$" && content[i + 1] === "{") { + stack.push("$"); + i++; + state = "code"; + regexAllowed = true; + } + continue; + } + + // state === "code" + if (c === "/" && content[i + 1] === "/") { + state = "line-comment"; + i++; + continue; + } + if (c === "/" && content[i + 1] === "*") { + state = "block-comment"; + i++; + continue; + } + if (isJsLike && c === "/") { + if (regexAllowed) { + const end = tryScanRegexLiteral(content, i); + if (end !== null) { + i = end - 1; + regexAllowed = false; + continue; + } + } + regexAllowed = true; + continue; + } + + if (c === "'") { + state = "single"; + continue; + } + if (c === '"') { + state = "double"; + continue; + } + if (c === "`") { + state = "template"; + continue; + } + + if (c === "(" || c === "[" || c === "{") { + stack.push(c); + regexAllowed = true; + continue; + } + if (c === ")" || c === "]" || c === "}") { + const top = stack[stack.length - 1]; + if (c === "}" && top === "$") { + stack.pop(); + state = "template"; + continue; + } + const expectedOpen = CLOSE_TO_OPEN[c]; + if (top !== expectedOpen) { + return `unbalanced '${c}' with no matching '${expectedOpen}'`; + } + stack.pop(); + regexAllowed = false; + continue; + } + + if (/[a-zA-Z_$]/.test(c)) { + let j = i; + while (j < content.length && /[a-zA-Z0-9_$]/.test(content[j])) j++; + const word = content.slice(i, j); + i = j - 1; + if (VALUE_KEYWORDS.has(word)) { + regexAllowed = false; + } else if (REGEX_ALLOWED_KEYWORDS.has(word)) { + regexAllowed = true; + } else { + // A plain identifier is value-like (e.g. `foo / bar`). + regexAllowed = false; + } + continue; + } + if (/[0-9]/.test(c)) { + let j = i; + while (j < content.length && /[0-9a-fA-F.xXeEoObB_]/.test(content[j])) j++; + i = j - 1; + regexAllowed = false; + continue; + } + if (/\s/.test(c)) { + continue; + } + // Any other punctuation/operator character: a `/` after it is allowed + // to start a regex (covers `=`, `!`, `&`, `|`, `?`, `+`, `-`, `*`, `%`, + // `^`, `~`, `<`, `>`, `,`, `;`, `:`, and `}` which is ambiguous but + // biased toward "regex allowed" since an unmatched guess falls back + // safely to division via the same-line lookahead in tryScanRegexLiteral). + regexAllowed = true; + } + + if (state === "single") return "unterminated single-quoted string at end of file"; + if (state === "double") return "unterminated double-quoted string at end of file"; + if (state === "template") return "unterminated template literal at end of file"; + if (state === "block-comment") return "unterminated block comment at end of file"; + if (stack.length > 0) { + const unclosed = stack[stack.length - 1]; + if (unclosed === "$") return "unterminated '${' template interpolation at end of file"; + return `unbalanced '${unclosed}' with no matching close`; + } + return undefined; +} + +/** + * Run a lightweight syntax sanity check on the final content of an edited + * file, keyed off the file extension. Returns a short warning reason, or + * undefined if the file type isn't covered or nothing looked wrong. + * + * This is advisory only - callers must never fail or reject an edit based on + * this result. + */ +export function checkSyntax(path: string, content: string): string | undefined { + const ext = extname(path).toLowerCase(); + + if (JSON_EXTENSIONS.has(ext)) { + try { + JSON.parse(content); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return undefined; + } + + if (BRACE_EXTENSIONS.has(ext)) { + return checkBraceBalance(content, JS_LIKE_EXTENSIONS.has(ext)); + } + + return undefined; +} diff --git a/packages/coding-agent/src/core/tools/tree-sitter-assets.ts b/packages/coding-agent/src/core/tools/tree-sitter-assets.ts new file mode 100644 index 00000000000..9a6553c0381 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-assets.ts @@ -0,0 +1,122 @@ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getPackageDir, getTreeSitterAssetsDir } from "../../config.ts"; + +const require = createRequire(import.meta.url); +const sourceToolsDir = dirname(fileURLToPath(import.meta.url)); + +interface RuntimeAsset { + packageName: string; + version: string; + wasm: string; +} + +export interface GrammarAsset { + packageName: string; + version: string; + wasm: string; + query: string; +} + +interface TreeSitterManifest { + runtime: RuntimeAsset; + grammars: Record; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseRuntimeAsset(value: unknown): RuntimeAsset { + if ( + !isRecord(value) || + typeof value.packageName !== "string" || + typeof value.version !== "string" || + typeof value.wasm !== "string" + ) { + throw new Error("Invalid Tree-sitter runtime manifest entry"); + } + return { packageName: value.packageName, version: value.version, wasm: value.wasm }; +} + +function parseGrammarAsset(value: unknown, id: string): GrammarAsset { + if ( + !isRecord(value) || + typeof value.packageName !== "string" || + typeof value.version !== "string" || + typeof value.wasm !== "string" || + typeof value.query !== "string" + ) { + throw new Error(`Invalid Tree-sitter grammar manifest entry: ${id}`); + } + return { packageName: value.packageName, version: value.version, wasm: value.wasm, query: value.query }; +} + +function parseManifest(value: unknown): TreeSitterManifest { + if (!isRecord(value) || !isRecord(value.grammars)) throw new Error("Invalid Tree-sitter manifest"); + const grammars: Record = {}; + for (const [id, grammar] of Object.entries(value.grammars)) grammars[id] = parseGrammarAsset(grammar, id); + return { runtime: parseRuntimeAsset(value.runtime), grammars }; +} + +function sourceManifestPath(): string { + return join(sourceToolsDir, "tree-sitter-manifest.json"); +} + +function isSourceTree(): boolean { + return existsSync(join(getPackageDir(), "src")); +} + +function manifestPath(): string { + return isSourceTree() ? sourceManifestPath() : join(getTreeSitterAssetsDir(), "manifest.json"); +} + +export const treeSitterManifest = parseManifest(JSON.parse(readFileSync(manifestPath(), "utf8"))); + +export type GrammarId = keyof typeof treeSitterManifest.grammars; + +const EXTENSION_GRAMMARS: Record = { + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".ts": "typescript", + ".tsx": "tsx", + ".py": "python", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".cs": "csharp", +}; + +function resolveSourcePackageAsset(packageName: string, asset: string): string { + return require.resolve(`${packageName}/${asset}`); +} + +export function grammarForExtension(extension: string): GrammarId | undefined { + return EXTENSION_GRAMMARS[extension.toLowerCase()]; +} + +export function resolveTreeSitterRuntime(): string { + if (isSourceTree()) + return resolveSourcePackageAsset(treeSitterManifest.runtime.packageName, treeSitterManifest.runtime.wasm); + return join(getTreeSitterAssetsDir(), treeSitterManifest.runtime.wasm); +} + +export function resolveTreeSitterGrammar(id: GrammarId): string { + const grammar = treeSitterManifest.grammars[id]; + if (!grammar) throw new Error(`Unknown Tree-sitter grammar: ${id}`); + if (isSourceTree()) return resolveSourcePackageAsset(grammar.packageName, grammar.wasm); + return join(getTreeSitterAssetsDir(), "grammars", grammar.wasm); +} + +export function readTreeSitterQuery(id: GrammarId): string { + const grammar = treeSitterManifest.grammars[id]; + if (!grammar) throw new Error(`Unknown Tree-sitter grammar: ${id}`); + const queryPath = isSourceTree() + ? join(sourceToolsDir, "tree-sitter-queries", grammar.query) + : join(getTreeSitterAssetsDir(), "queries", grammar.query); + return readFileSync(queryPath, "utf8"); +} diff --git a/packages/coding-agent/src/core/tools/tree-sitter-manifest.json b/packages/coding-agent/src/core/tools/tree-sitter-manifest.json new file mode 100644 index 00000000000..f4a84b6e774 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-manifest.json @@ -0,0 +1,17 @@ +{ + "runtime": { + "packageName": "web-tree-sitter", + "version": "0.26.11", + "wasm": "web-tree-sitter.wasm" + }, + "grammars": { + "javascript": { "packageName": "@lumis-sh/wasm-javascript", "version": "0.26.2", "wasm": "tree-sitter-javascript.wasm", "query": "javascript.scm" }, + "typescript": { "packageName": "@lumis-sh/wasm-typescript", "version": "0.26.1", "wasm": "tree-sitter-typescript.wasm", "query": "typescript.scm" }, + "tsx": { "packageName": "@lumis-sh/wasm-tsx", "version": "0.26.1", "wasm": "tree-sitter-tsx.wasm", "query": "tsx.scm" }, + "python": { "packageName": "@lumis-sh/wasm-python", "version": "0.26.2", "wasm": "tree-sitter-python.wasm", "query": "python.scm" }, + "go": { "packageName": "@lumis-sh/wasm-go", "version": "0.26.1", "wasm": "tree-sitter-go.wasm", "query": "go.scm" }, + "rust": { "packageName": "@lumis-sh/wasm-rust", "version": "0.26.3", "wasm": "tree-sitter-rust.wasm", "query": "rust.scm" }, + "java": { "packageName": "@lumis-sh/wasm-java", "version": "0.26.1", "wasm": "tree-sitter-java.wasm", "query": "java.scm" }, + "csharp": { "packageName": "@lumis-sh/wasm-csharp", "version": "0.26.1", "wasm": "tree-sitter-csharp.wasm", "query": "csharp.scm" } + } +} diff --git a/packages/coding-agent/src/core/tools/tree-sitter-navigation.ts b/packages/coding-agent/src/core/tools/tree-sitter-navigation.ts new file mode 100644 index 00000000000..287ef5eb7db --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-navigation.ts @@ -0,0 +1,220 @@ +import { extname } from "node:path"; +import { Language, Parser, Query, type Node as SyntaxNode } from "web-tree-sitter"; +import { + type GrammarId, + grammarForExtension, + readTreeSitterQuery, + resolveTreeSitterGrammar, + resolveTreeSitterRuntime, +} from "./tree-sitter-assets.ts"; + +export type SyntaxDeclarationKind = + | "class" + | "constructor" + | "enum" + | "function" + | "implementation" + | "interface" + | "method" + | "struct" + | "trait" + | "type"; + +export interface SyntaxDeclaration { + readonly name: string; + readonly kind: SyntaxDeclarationKind; + readonly startIndex: number; + readonly endIndex: number; + readonly startLine: number; + readonly parentKind: SyntaxDeclarationKind | undefined; +} + +const TYPE_LIKE_KINDS = new Set([ + "class", + "enum", + "implementation", + "interface", + "struct", + "trait", +]); +const DECLARATION_WRAPPERS = new Set(["export_statement"]); + +let parserInitialization: Promise | undefined; +const languagePromises = new Map>(); +const queryPromises = new Map>(); + +function initializeParser(): Promise { + parserInitialization ??= Parser.init({ locateFile: () => resolveTreeSitterRuntime() }); + return parserInitialization; +} + +function loadLanguage(id: GrammarId): Promise { + let promise = languagePromises.get(id); + if (!promise) { + promise = Language.load(resolveTreeSitterGrammar(id)); + languagePromises.set(id, promise); + } + return promise; +} + +async function loadQuery(id: GrammarId): Promise { + let promise = queryPromises.get(id); + if (!promise) { + promise = loadLanguage(id).then((language) => new Query(language, readTreeSitterQuery(id))); + queryPromises.set(id, promise); + } + return promise; +} + +function declarationNode(node: SyntaxNode): SyntaxNode { + let result = node; + while (result.parent && DECLARATION_WRAPPERS.has(result.parent.type)) result = result.parent; + return result; +} + +function byteLength(codePoint: number): number { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +function createLineOffsetMaps(source: string): Map[] { + const lineOffsets: Map[] = []; + let offsets = new Map(); + let byteOffset = 0; + for (let stringOffset = 0; stringOffset < source.length; ) { + const codePoint = source.codePointAt(stringOffset); + if (codePoint === 0x0a || codePoint === 0x0d) { + offsets.set(byteOffset, stringOffset); + lineOffsets.push(offsets); + if (codePoint === 0x0d && source[stringOffset + 1] === "\n") stringOffset++; + stringOffset++; + offsets = new Map(); + byteOffset = 0; + continue; + } + offsets.set(byteOffset, stringOffset); + if (codePoint === undefined) break; + byteOffset += byteLength(codePoint); + stringOffset += codePoint > 0xffff ? 2 : 1; + } + offsets.set(byteOffset, source.length); + lineOffsets.push(offsets); + return lineOffsets; +} + +function stringOffsetForPosition(lineOffsets: readonly Map[], row: number, byteColumn: number): number { + const offset = lineOffsets[row]?.get(byteColumn); + if (offset === undefined) + throw new Error(`Tree-sitter range does not align with a UTF-8 character boundary: ${row}:${byteColumn}`); + return offset; +} + +function syntaxKind(captureName: string): SyntaxDeclarationKind | undefined { + const kind = captureName.slice("definition.".length); + if ( + kind === "class" || + kind === "constructor" || + kind === "enum" || + kind === "function" || + kind === "implementation" || + kind === "interface" || + kind === "method" || + kind === "struct" || + kind === "trait" || + kind === "type" + ) { + return kind; + } + return undefined; +} + +interface PendingDeclaration { + name: string; + kind: SyntaxDeclarationKind; + startByte: number; + endByte: number; + startIndex: number; + endIndex: number; + startLine: number; +} + +function nearestParentKind( + declaration: PendingDeclaration, + declarations: readonly PendingDeclaration[], +): SyntaxDeclarationKind | undefined { + let parent: PendingDeclaration | undefined; + for (const candidate of declarations) { + if (candidate.startByte >= declaration.startByte || candidate.endByte < declaration.endByte) continue; + if (!parent || candidate.startByte >= parent.startByte) parent = candidate; + } + return parent?.kind; +} + +export function isTypeLikeDeclaration(kind: SyntaxDeclarationKind | undefined): boolean { + return kind !== undefined && TYPE_LIKE_KINDS.has(kind); +} + +export async function findDeclarations( + filePath: string, + source: string, +): Promise { + const grammarId = grammarForExtension(extname(filePath)); + if (!grammarId) return undefined; + + await initializeParser(); + const [language, query] = await Promise.all([loadLanguage(grammarId), loadQuery(grammarId)]); + const parser = new Parser(); + parser.setLanguage(language); + const tree = parser.parse(source); + if (!tree) { + parser.delete(); + return []; + } + + try { + const lineOffsets = createLineOffsetMaps(source); + const declarations = new Map(); + for (const match of query.matches(tree.rootNode)) { + const definitionCapture = match.captures.find((capture) => capture.name.startsWith("definition.")); + const nameCapture = match.captures.find((capture) => capture.name === "name"); + if (!definitionCapture || !nameCapture) continue; + const kind = syntaxKind(definitionCapture.name); + if (!kind) continue; + + const node = declarationNode(definitionCapture.node); + const name = nameCapture.node.text.normalize("NFC"); + if (!name || node.startIndex >= node.endIndex) continue; + const pending: PendingDeclaration = { + name, + kind, + startByte: node.startIndex, + endByte: node.endIndex, + startIndex: stringOffsetForPosition(lineOffsets, node.startPosition.row, node.startPosition.column), + endIndex: stringOffsetForPosition(lineOffsets, node.endPosition.row, node.endPosition.column), + startLine: node.startPosition.row + 1, + }; + declarations.set(`${nameCapture.node.startIndex}:${nameCapture.node.endIndex}:${pending.kind}`, pending); + } + + const values = [...declarations.values()].sort((a, b) => a.startByte - b.startByte || a.endByte - b.endByte); + return values.map((declaration) => { + const parentKind = nearestParentKind(declaration, values); + return { + name: declaration.name, + kind: + grammarId === "python" && declaration.kind === "function" && isTypeLikeDeclaration(parentKind) + ? "method" + : declaration.kind, + startIndex: declaration.startIndex, + endIndex: declaration.endIndex, + startLine: declaration.startLine, + parentKind, + }; + }); + } finally { + tree.delete(); + parser.delete(); + } +} diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/csharp.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/csharp.scm new file mode 100644 index 00000000000..066f34e5bbb --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/csharp.scm @@ -0,0 +1,7 @@ +(class_declaration name: (identifier) @name) @definition.class +(interface_declaration name: (identifier) @name) @definition.interface +(struct_declaration name: (identifier) @name) @definition.struct +(enum_declaration name: (identifier) @name) @definition.enum +(record_declaration name: (identifier) @name) @definition.class +(method_declaration name: (identifier) @name) @definition.method +(constructor_declaration name: (identifier) @name) @definition.constructor diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/go.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/go.scm new file mode 100644 index 00000000000..d45b0fd82d0 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/go.scm @@ -0,0 +1,3 @@ +(function_declaration name: (identifier) @name) @definition.function +(method_declaration name: (field_identifier) @name) @definition.method +((type_declaration (type_spec name: (type_identifier) @name)) @definition.type) diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/java.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/java.scm new file mode 100644 index 00000000000..119eedc253f --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/java.scm @@ -0,0 +1,6 @@ +(class_declaration name: (identifier) @name) @definition.class +(interface_declaration name: (identifier) @name) @definition.interface +(enum_declaration name: (identifier) @name) @definition.enum +(record_declaration name: (identifier) @name) @definition.class +(method_declaration name: (identifier) @name) @definition.method +(constructor_declaration name: (identifier) @name) @definition.constructor diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/javascript.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/javascript.scm new file mode 100644 index 00000000000..f2992dd3268 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/javascript.scm @@ -0,0 +1,6 @@ +(class_declaration name: (identifier) @name) @definition.class +(function_declaration name: (identifier) @name) @definition.function +(generator_function_declaration name: (identifier) @name) @definition.function +(method_definition name: (property_identifier) @name) @definition.method +((lexical_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @definition.function) +((variable_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @definition.function) diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/python.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/python.scm new file mode 100644 index 00000000000..bfb97353c13 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/python.scm @@ -0,0 +1,2 @@ +(function_definition name: (identifier) @name) @definition.function +(class_definition name: (identifier) @name) @definition.class diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/rust.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/rust.scm new file mode 100644 index 00000000000..d73d826f6c7 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/rust.scm @@ -0,0 +1,5 @@ +(function_item name: (identifier) @name) @definition.function +(struct_item name: (type_identifier) @name) @definition.struct +(enum_item name: (type_identifier) @name) @definition.enum +(trait_item name: (type_identifier) @name) @definition.trait +(impl_item type: (type_identifier) @name) @definition.implementation diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/tsx.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/tsx.scm new file mode 100644 index 00000000000..0e117a9ca40 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/tsx.scm @@ -0,0 +1,11 @@ +(class_declaration name: (type_identifier) @name) @definition.class +(abstract_class_declaration name: (type_identifier) @name) @definition.class +(interface_declaration name: (type_identifier) @name) @definition.interface +(type_alias_declaration name: (type_identifier) @name) @definition.type +(enum_declaration name: (identifier) @name) @definition.enum +(function_declaration name: (identifier) @name) @definition.function +(generator_function_declaration name: (identifier) @name) @definition.function +(method_definition name: (property_identifier) @name) @definition.method +(method_signature name: (property_identifier) @name) @definition.method +((lexical_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @definition.function) +((variable_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @definition.function) diff --git a/packages/coding-agent/src/core/tools/tree-sitter-queries/typescript.scm b/packages/coding-agent/src/core/tools/tree-sitter-queries/typescript.scm new file mode 100644 index 00000000000..0e117a9ca40 --- /dev/null +++ b/packages/coding-agent/src/core/tools/tree-sitter-queries/typescript.scm @@ -0,0 +1,11 @@ +(class_declaration name: (type_identifier) @name) @definition.class +(abstract_class_declaration name: (type_identifier) @name) @definition.class +(interface_declaration name: (type_identifier) @name) @definition.interface +(type_alias_declaration name: (type_identifier) @name) @definition.type +(enum_declaration name: (identifier) @name) @definition.enum +(function_declaration name: (identifier) @name) @definition.function +(generator_function_declaration name: (identifier) @name) @definition.function +(method_definition name: (property_identifier) @name) @definition.method +(method_signature name: (property_identifier) @name) @definition.method +((lexical_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @definition.function) +((variable_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @definition.function) diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d4ca9831f2..e7dfe35c757 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -218,8 +218,11 @@ export { createFindTool, createGrepTool, createLsTool, + createOutlineTool, createReadOnlyTools, createReadTool, + createRecallTool, + createSymbolTool, createWriteTool, type PromptTemplate, } from "./core/sdk.ts"; @@ -285,7 +288,10 @@ export { createGrepToolDefinition, createLocalBashOperations, createLsToolDefinition, + createOutlineToolDefinition, createReadToolDefinition, + createRecallToolDefinition, + createSymbolToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -306,10 +312,19 @@ export { type LsToolDetails, type LsToolInput, type LsToolOptions, + type OutlineOperations, + type OutlineToolDetails, + type OutlineToolInput, + type OutlineToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, type ReadToolOptions, + type RecallToolDetails, + type RecallToolInput, + type SymbolToolDetails, + type SymbolToolInput, + type SymbolToolOptions, type ToolsOptions, type TruncationOptions, type TruncationResult, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 42bdb9d27ab..93500211d0a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4101,7 +4101,7 @@ export class InteractiveMode { showError(errorMessage: string): void { this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); + this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), this.outputPad, 0)); this.ui.requestRender(); } diff --git a/packages/coding-agent/test/compaction-extensions-example.test.ts b/packages/coding-agent/test/compaction-extensions-example.test.ts index ccc144b21a3..906090f7115 100644 --- a/packages/coding-agent/test/compaction-extensions-example.test.ts +++ b/packages/coding-agent/test/compaction-extensions-example.test.ts @@ -109,7 +109,6 @@ describe("Documentation example", () => { ui: { notify: vi.fn() }, modelRegistry: { find: vi.fn(() => model), - getApiKeyAndHeaders: vi.fn(async () => ({ ok: true, apiKey: "fake-key" })), complete, }, }, @@ -118,7 +117,12 @@ describe("Documentation example", () => { expect(complete).toHaveBeenCalledWith( model, expect.objectContaining({ messages: expect.any(Array) }), - expect.objectContaining({ apiKey: "fake-key", maxTokens: 8192 }), + expect.objectContaining({ maxTokens: 8192 }), + ); + expect(complete).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ apiKey: expect.anything() }), ); expect(result).toMatchObject({ compaction: { diff --git a/packages/coding-agent/test/model-runtime-auth-options.test.ts b/packages/coding-agent/test/model-runtime-auth-options.test.ts index 85d9963c0df..cec2824507b 100644 --- a/packages/coding-agent/test/model-runtime-auth-options.test.ts +++ b/packages/coding-agent/test/model-runtime-auth-options.test.ts @@ -260,7 +260,10 @@ describe("ModelRuntime auth options", () => { await runtime.getAuth("extension-oauth", { signal: controller.signal }); expect(refreshSignal).toBeInstanceOf(AbortSignal); - expect(refreshSignal).not.toBe(controller.signal); + const reason = new Error("cancelled"); + controller.abort(reason); + expect(refreshSignal?.aborted).toBe(true); + expect(refreshSignal?.reason).toBe(reason); }); it("does not fabricate an API key method for an extension OAuth-only provider", async () => { diff --git a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts index 122f6cc5022..ecc6545a94d 100644 --- a/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts +++ b/packages/coding-agent/test/model-runtime-modify-models-compat.test.ts @@ -1,7 +1,14 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createAssistantMessageEventStream, InMemoryModelsStore, type Model, type Provider } from "@cheetahbyte/pi-ai"; +import { + createAssistantMessageEventStream, + type DeferredCancelOptions, + type DeferredFetchOptions, + InMemoryModelsStore, + type Model, + type Provider, +} from "@cheetahbyte/pi-ai"; import { describe, expect, it } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRegistry } from "../src/core/model-registry.ts"; @@ -108,7 +115,9 @@ describe("extension provider model lifecycle", () => { baseUrl: "https://native.test/v1", }; let fetchedBaseUrl: string | undefined; + let fetchedOptions: DeferredFetchOptions | undefined; let cancelledId: string | undefined; + let cancelledOptions: DeferredCancelOptions | undefined; const provider: Provider = { id: "extension-native-deferred", name: "Extension Native Deferred", @@ -125,8 +134,9 @@ describe("extension provider model lifecycle", () => { streamSimple: () => { throw new Error("unused"); }, - fetchDeferred: (requestModel) => { + fetchDeferred: (requestModel, _handle, options) => { fetchedBaseUrl = requestModel.baseUrl; + fetchedOptions = options; const message = { role: "assistant" as const, content: [], @@ -150,8 +160,9 @@ describe("extension provider model lifecycle", () => { stream.end(message); return stream; }, - cancelDeferred: async (_requestModel, handle) => { + cancelDeferred: async (_requestModel, handle, options) => { cancelledId = handle.id; + cancelledOptions = options; }, }; @@ -159,21 +170,46 @@ describe("extension provider model lifecycle", () => { const composedModel = runtime.getModel(provider.id, nativeModel.id); expect(composedModel).toBeDefined(); - await runtime.fetchDeferred(composedModel!, { - provider: provider.id, - modelId: nativeModel.id, - api: nativeModel.api, - id: "fetch-id", - }); - await runtime.cancelDeferred(composedModel!, { - provider: provider.id, - modelId: nativeModel.id, - api: nativeModel.api, - id: "cancel-id", - }); + await runtime.fetchDeferred( + composedModel!, + { + provider: provider.id, + modelId: nativeModel.id, + api: nativeModel.api, + id: "fetch-id", + }, + { + wait: 25, + headers: { "X-Fetch": "fetch" }, + transformHeaders: (headers) => ({ ...headers, "X-Transformed": "fetch" }), + }, + ); + await runtime.cancelDeferred( + composedModel!, + { + provider: provider.id, + modelId: nativeModel.id, + api: nativeModel.api, + id: "cancel-id", + }, + { + timeoutMs: 100, + transformHeaders: (headers) => ({ ...headers, "X-Transformed": "cancel" }), + }, + ); expect(fetchedBaseUrl).toBe("https://overlay.test/v1"); + expect(fetchedOptions).toMatchObject({ + apiKey: "key", + wait: 25, + headers: { "X-Fetch": "fetch", "X-Transformed": "fetch" }, + }); expect(cancelledId).toBe("cancel-id"); + expect(cancelledOptions).toMatchObject({ + apiKey: "key", + timeoutMs: 100, + headers: { "X-Transformed": "cancel" }, + }); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/packages/coding-agent/test/outline-tool.test.ts b/packages/coding-agent/test/outline-tool.test.ts new file mode 100644 index 00000000000..e2405d2ac66 --- /dev/null +++ b/packages/coding-agent/test/outline-tool.test.ts @@ -0,0 +1,221 @@ +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createOutlineTool } from "../src/index.ts"; + +function getTextOutput(result: any): string { + return ( + result.content + ?.filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join("\n") || "" + ); +} + +describe("outline tool", () => { + let testDir: string; + const outlineTool = createOutlineTool(process.cwd()); + + beforeEach(() => { + testDir = join(tmpdir(), `coding-agent-outline-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("extracts top-level functions/classes and nested methods from a JS/TS fixture", async () => { + const testFile = join(testDir, "example.ts"); + const content = [ + /* 1 */ "import { foo } from './foo';", + /* 2 */ "", + /* 3 */ "export interface Point {", + /* 4 */ " x: number;", + /* 5 */ "}", + /* 6 */ "", + /* 7 */ "export type Vector = Point[];", + /* 8 */ "", + /* 9 */ "export class Shape {", + /*10 */ " private name: string;", + /*11 */ "", + /*12 */ " constructor(name: string) {", + /*13 */ " this.name = name;", + /*14 */ " }", + /*15 */ "", + /*16 */ " area(): number {", + /*17 */ " return 0;", + /*18 */ " }", + /*19 */ "}", + /*20 */ "", + /*21 */ "export function makeShape(name: string): Shape {", + /*22 */ " return new Shape(name);", + /*23 */ "}", + /*24 */ "", + /*25 */ "export const scale = (v: Vector, factor: number): Vector => {", + /*26 */ " return v;", + /*27 */ "};", + ].join("\n"); + writeFileSync(testFile, content); + + const result = await outlineTool.execute("test-call-1", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("3: export interface Point"); + expect(output).toContain("7: export type Vector = Point[]"); + expect(output).toContain("9: export class Shape"); + expect(output).toContain(" 12: constructor(name: string)"); + expect(output).toContain(" 16: area(): number"); + expect(output).toContain("21: export function makeShape(name: string): Shape"); + expect(output).toContain("25: export const scale = (v: Vector, factor: number): Vector =>"); + // Method bodies should not leak in as separate declarations. + expect(output).not.toContain("return 0"); + expect(result.details).toBeUndefined(); + }); + + it("extracts def/class from a Python fixture with nested methods", async () => { + const testFile = join(testDir, "example.py"); + const content = [ + /*1*/ "import os", + /*2*/ "", + /*3*/ "def top_level(x):", + /*4*/ " return x", + /*5*/ "", + /*6*/ "class Animal:", + /*7*/ " def __init__(self, name):", + /*8*/ " self.name = name", + /*9*/ "", + /*10*/ " def speak(self):", + /*11*/ " return 'noise'", + /*12*/ "", + /*13*/ "def another_top_level():", + /*14*/ " pass", + ].join("\n"); + writeFileSync(testFile, content); + + const result = await outlineTool.execute("test-call-2", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("3: def top_level(x)"); + expect(output).toContain("6: class Animal"); + expect(output).toContain(" 7: def __init__(self, name)"); + expect(output).toContain(" 10: def speak(self)"); + expect(output).toContain("13: def another_top_level()"); + expect(output).not.toContain("return x"); + }); + + it("handles decorators, multiline signatures, and template-literal braces", async () => { + const testFile = join(testDir, "advanced.ts"); + const templateExpression = `\${value}`; + writeFileSync( + testFile, + [ + "@trace", + "export async function load(", + " value: T,", + "): Promise {", + ` const message = \`${templateExpression}\`;`, + " return value;", + "}", + ].join("\n"), + ); + + const result = await outlineTool.execute("test-call-advanced", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("1: export async function load("); + expect(output).not.toContain(templateExpression); + }); + + it("returns a not-supported message (not an error) for an unrecognized extension", async () => { + const testFile = join(testDir, "notes.md"); + writeFileSync(testFile, "# Title\n\nSome text.\n"); + + const result = await outlineTool.execute("test-call-3", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toBe("No outline support for this file type; use read instead."); + expect(result.details).toBeUndefined(); + }); + + it("handles an empty file without crashing", async () => { + const testFile = join(testDir, "empty.ts"); + writeFileSync(testFile, ""); + + const result = await outlineTool.execute("test-call-4", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toBe("(no top-level declarations found)"); + }); + + it("truncates with a notice when a file has an unusually large number of declarations", async () => { + const testFile = join(testDir, "many.go"); + const lines: string[] = []; + for (let i = 0; i < 600; i++) { + lines.push(`func Fn${i}() {`); + lines.push(`\treturn`); + lines.push(`}`); + } + writeFileSync(testFile, lines.join("\n")); + + const result = await outlineTool.execute("test-call-5", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("func Fn0()"); + expect(output).toContain("declarations limit reached"); + expect(result.details).toBeDefined(); + expect(result.details?.entryLimitReached).toBe(500); + }); + + it("extracts Go func/type declarations without picking up locals inside function bodies", async () => { + const testFile = join(testDir, "example.go"); + const content = [ + /*1*/ "package main", + /*2*/ "", + /*3*/ "type Point struct {", + /*4*/ "\tX int", + /*5*/ "}", + /*6*/ "", + /*7*/ "func (p *Point) Move(dx int) {", + /*8*/ "\ttype local struct{}", + /*9*/ "\tp.X += dx", + /*10*/ "}", + /*11*/ "", + /*12*/ "func NewPoint() *Point {", + /*13*/ "\treturn &Point{}", + /*14*/ "}", + ].join("\n"); + writeFileSync(testFile, content); + + const result = await outlineTool.execute("test-call-6", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("3: type Point struct {".replace(" {", "")); + expect(output).toContain("7: func (p *Point) Move(dx int)"); + expect(output).toContain("12: func NewPoint() *Point"); + expect(output).not.toContain("local struct"); + }); + + it("rejects directories", async () => { + await expect(outlineTool.execute("test-call-7", { path: testDir })).rejects.toThrow(/Not a file/); + }); + + it("rejects missing files", async () => { + const missing = join(testDir, "missing.ts"); + await expect(outlineTool.execute("test-call-8", { path: missing })).rejects.toThrow(/Path not found/); + }); + + it("returns a clear message instead of parsing a file over the size limit", async () => { + const testFile = join(testDir, "big.ts"); + writeFileSync(testFile, "export function ok() {}\n"); + + const limitedTool = createOutlineTool(process.cwd(), { maxFileSizeBytes: 10 }); + const result = await limitedTool.execute("test-call-9", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("too large to parse"); + expect(output).toMatch(/use grep or read with an offset/); + expect(result.details).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/recall-tool.test.ts b/packages/coding-agent/test/recall-tool.test.ts new file mode 100644 index 00000000000..c14aab171fe --- /dev/null +++ b/packages/coding-agent/test/recall-tool.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { ExtensionContext } from "../src/core/extensions/index.ts"; +import type { SessionEntry } from "../src/core/session-manager.ts"; +import { createRecallToolDefinition } from "../src/core/tools/recall.ts"; + +function makeCtx(entries: SessionEntry[]): ExtensionContext { + return { + sessionManager: { + getEntries: () => entries, + }, + } as unknown as ExtensionContext; +} + +function messageEntry(id: string, role: "user" | "assistant", text: string, timestamp: string): SessionEntry { + return { + type: "message", + id, + parentId: null, + timestamp, + message: { + role, + content: [{ type: "text", text }], + }, + } as unknown as SessionEntry; +} + +describe("recall tool", () => { + it("finds a keyword present only in an old entry evicted from live context by compaction", async () => { + const entries: SessionEntry[] = [ + messageEntry("1", "user", "Please look into the ZORKMID-4471 stack trace", "2026-08-01T10:00:00.000Z"), + messageEntry( + "2", + "assistant", + "The error was: TypeError: Cannot read properties of undefined (reading 'foo') at ZORKMID-4471", + "2026-08-01T10:00:05.000Z", + ), + { + type: "compaction", + id: "3", + parentId: "2", + timestamp: "2026-08-01T10:05:00.000Z", + summary: "Investigated a stack trace and fixed the bug.", + firstKeptEntryId: "4", + tokensBefore: 5000, + } as unknown as SessionEntry, + messageEntry("4", "user", "Great, thanks for the fix!", "2026-08-01T10:05:10.000Z"), + messageEntry("5", "assistant", "You're welcome.", "2026-08-01T10:05:12.000Z"), + ]; + + const tool = createRecallToolDefinition("/tmp/project"); + const result = await tool.execute("call-1", { query: "ZORKMID-4471" }, undefined, undefined, makeCtx(entries)); + + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text).toContain("ZORKMID-4471"); + // Both the original mention and the error message referencing it should be found. + expect((text.match(/ZORKMID-4471/g) ?? []).length).toBeGreaterThanOrEqual(2); + }); + + it("is case-insensitive", async () => { + const entries: SessionEntry[] = [ + messageEntry("1", "user", "The Widget Factory failed", "2026-08-01T10:00:00.000Z"), + ]; + const tool = createRecallToolDefinition("/tmp/project"); + + const result = await tool.execute("call-1", { query: "widget factory" }, undefined, undefined, makeCtx(entries)); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text).toContain("Widget Factory"); + }); + + it("returns a clear empty-result message when nothing matches", async () => { + const entries: SessionEntry[] = [messageEntry("1", "user", "hello there", "2026-08-01T10:00:00.000Z")]; + const tool = createRecallToolDefinition("/tmp/project"); + + const result = await tool.execute( + "call-1", + { query: "nonexistent-keyword-xyz" }, + undefined, + undefined, + makeCtx(entries), + ); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text.toLowerCase()).toContain("no matches"); + expect(result.details).toBeUndefined(); + }); + + it("respects limit and truncates with a notice when there are many matches", async () => { + const entries: SessionEntry[] = []; + for (let i = 0; i < 30; i++) { + entries.push(messageEntry(`id-${i}`, "user", `needle occurrence number ${i}`, "2026-08-01T10:00:00.000Z")); + } + const tool = createRecallToolDefinition("/tmp/project"); + + const result = await tool.execute( + "call-1", + { query: "needle", limit: 10 }, + undefined, + undefined, + makeCtx(entries), + ); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + const occurrences = (text.match(/needle occurrence/g) ?? []).length; + expect(occurrences).toBe(10); + expect(text).toContain("limit reached"); + expect(result.details).toBeDefined(); + }); + + it("returns the graceful 'no active session' message when ctx is undefined", async () => { + // The ToolDefinition interface types `ctx` as required, but at runtime (e.g. a + // plain AgentTool invoked outside a full session runtime, per + // tool-definition-wrapper.ts) it can be undefined. Cast to exercise that path. + const tool = createRecallToolDefinition("/tmp/project"); + const result = await tool.execute( + "call-1", + { query: "anything" }, + undefined, + undefined, + undefined as unknown as ExtensionContext, + ); + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text.toLowerCase()).toContain("recall is unavailable"); + expect(result.details).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-constraints-reminder.test.ts b/packages/coding-agent/test/suite/agent-session-constraints-reminder.test.ts new file mode 100644 index 00000000000..838b9dfc1c6 --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-constraints-reminder.test.ts @@ -0,0 +1,153 @@ +import { fauxAssistantMessage } from "@cheetahbyte/pi-ai"; +import type { Context, FauxResponseFactory } from "@cheetahbyte/pi-ai/compat"; +import { afterEach, describe, expect, it } from "vitest"; +import { CONSTRAINTS_REMINDER_INTERVAL_TURNS } from "../../src/core/compaction/index.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +const REMINDER_TEXT = "Reminder — constraints and preferences from earlier in this session:"; +const CONSTRAINT_BULLET = "Never touch the production database directly"; + +const SUMMARY_WITH_CONSTRAINTS = `## Goal +Ship the export feature. + +## Constraints & Preferences +- ${CONSTRAINT_BULLET} +- Always run \`npm run check\` before finishing + +## Progress +### Done +- [x] Set up scaffolding + +### In Progress +- [ ] Wire up export button + +### Blocked +- (none) + +## Key Decisions +- **Use CSV**: simplest format for the user's needs + +## Next Steps +1. Finish the export button + +## Critical Context +- (none)`; + +const SUMMARY_WITHOUT_CONSTRAINTS = `## Goal +Ship the export feature. + +## Constraints & Preferences +- (none) + +## Progress +### Done +- [x] Set up scaffolding + +## Next Steps +1. Finish the export button`; + +function messageText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + return content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +function findReminderMessage(context: Context) { + return context.messages.find( + (message) => message.role === "user" && messageText(message.content).includes(REMINDER_TEXT), + ); +} + +function contextHasReminder(context: Context): boolean { + return findReminderMessage(context) !== undefined; +} + +/** Seed a compaction entry directly on the session, bypassing real summarization. */ +function seedCompaction(harness: Harness, summary: string): void { + const keptId = harness.sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "kept context before compaction" }], + timestamp: Date.now(), + }); + harness.sessionManager.appendCompaction(summary, keptId, 1000); + harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages; +} + +/** Drive `turnCount` sequential prompts, capturing the outgoing LLM context for each. */ +async function driveTurns(harness: Harness, turnCount: number): Promise { + const capturedContexts: Context[] = []; + const responses: FauxResponseFactory[] = []; + for (let i = 0; i < turnCount; i++) { + responses.push((context) => { + capturedContexts.push(context); + return fauxAssistantMessage(`assistant reply ${i}`); + }); + } + harness.setResponses(responses); + + for (let i = 0; i < turnCount; i++) { + await harness.session.prompt(`user turn ${i + 1}`); + } + + return capturedContexts; +} + +describe("constraints reminder re-surfacing", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("stays silent before the interval and appears at the threshold turn, then periodically", async () => { + const harness = await createHarness(); + harnesses.push(harness); + seedCompaction(harness, SUMMARY_WITH_CONSTRAINTS); + + const turnsToRun = CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2 + 1; + const contexts = await driveTurns(harness, turnsToRun); + + expect(contexts).toHaveLength(turnsToRun); + + for (let turn = 1; turn < CONSTRAINTS_REMINDER_INTERVAL_TURNS; turn++) { + expect(contextHasReminder(contexts[turn - 1]!), `turn ${turn} should not carry the reminder`).toBe(false); + } + + const reminderMessage = findReminderMessage(contexts[CONSTRAINTS_REMINDER_INTERVAL_TURNS - 1]!); + expect(reminderMessage).toBeDefined(); + expect(messageText(reminderMessage!.content)).toContain(CONSTRAINT_BULLET); + + for (let turn = CONSTRAINTS_REMINDER_INTERVAL_TURNS + 1; turn < CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2; turn++) { + expect(contextHasReminder(contexts[turn - 1]!), `turn ${turn} should not carry the reminder`).toBe(false); + } + + expect(contextHasReminder(contexts[CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2 - 1]!)).toBe(true); + }); + + it("never surfaces a reminder without a prior compaction", async () => { + const harness = await createHarness(); + harnesses.push(harness); + + const contexts = await driveTurns(harness, CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2); + + for (const context of contexts) { + expect(contextHasReminder(context)).toBe(false); + } + }); + + it("stays silent when the compaction summary has no constraints", async () => { + const harness = await createHarness(); + harnesses.push(harness); + seedCompaction(harness, SUMMARY_WITHOUT_CONSTRAINTS); + + const contexts = await driveTurns(harness, CONSTRAINTS_REMINDER_INTERVAL_TURNS * 2); + + for (const context of contexts) { + expect(contextHasReminder(context)).toBe(false); + } + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts index 16f536cf1cd..ac419fdcd52 100644 --- a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts +++ b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts @@ -78,7 +78,7 @@ describe("regression #3592: no-builtin-tools keeps extension tools enabled", () .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "outline", "read", "recall", "symbol", "write"]); expect(session.getActiveToolNames()).toEqual(["dynamic_tool"]); expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); expect(session.systemPrompt).not.toContain("- read:"); diff --git a/packages/coding-agent/test/suite/regressions/7572-provider-retry-settings-merge.test.ts b/packages/coding-agent/test/suite/regressions/7572-provider-retry-settings-merge.test.ts new file mode 100644 index 00000000000..de6cd686a9b --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/7572-provider-retry-settings-merge.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { InMemorySettingsStorage, SettingsManager } from "../../../src/core/settings-manager.ts"; + +describe("regression #7572: nested provider retry settings merge", () => { + it("preserves global provider settings not overridden by the project", () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => + JSON.stringify({ + retry: { + provider: { + timeoutMs: 30000, + maxRetryDelayMs: 45000, + }, + }, + }), + ); + storage.withLock("project", () => + JSON.stringify({ + retry: { + provider: { + maxRetries: 2, + }, + }, + }), + ); + + const settingsManager = SettingsManager.fromStorage(storage); + + expect(settingsManager.getProviderRetrySettings()).toEqual({ + timeoutMs: 30000, + maxRetries: 2, + maxRetryDelayMs: 45000, + }); + }); +}); diff --git a/packages/coding-agent/test/symbol-tool.test.ts b/packages/coding-agent/test/symbol-tool.test.ts new file mode 100644 index 00000000000..5d5f7f25152 --- /dev/null +++ b/packages/coding-agent/test/symbol-tool.test.ts @@ -0,0 +1,306 @@ +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createSymbolTool } from "../src/index.ts"; + +// Helper to extract text from content blocks +function getTextOutput(result: any): string { + return ( + result.content + ?.filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join("\n") || "" + ); +} + +describe("symbol tool", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `coding-agent-symbol-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("finds a TS function declaration with its body", async () => { + const testFile = join(testDir, "example.ts"); + writeFileSync( + testFile, + [ + "export function greet(name: string): string {", + " const message = 'Hello, ' + name + '!';", + " return message;", + "}", + "", + ].join("\n"), + ); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-1", { name: "greet" }); + const output = getTextOutput(result); + + expect(output).toContain("example.ts:1: [function]"); + expect(output).toContain("export function greet(name: string): string {"); + expect(output).toContain("return message;"); + expect(output).toContain("}"); + }); + + it("finds a TS class declaration and a class method with correct bodies", async () => { + const testFile = join(testDir, "widget.ts"); + writeFileSync( + testFile, + [ + "export class Widget {", + " private count = 0;", + "", + " increment() {", + " this.count += 1;", + " return this.count;", + " }", + "}", + "", + ].join("\n"), + ); + + const tool = createSymbolTool(testDir); + + const classResult = await tool.execute("call-2", { name: "Widget" }); + const classOutput = getTextOutput(classResult); + expect(classOutput).toContain("widget.ts:1: [class]"); + expect(classOutput).toContain("export class Widget {"); + expect(classOutput).toContain("increment()"); + + const methodResult = await tool.execute("call-3", { name: "increment" }); + const methodOutput = getTextOutput(methodResult); + expect(methodOutput).toContain("widget.ts:4: [method]"); + expect(methodOutput).toContain("increment() {"); + expect(methodOutput).toContain("this.count += 1;"); + expect(methodOutput).not.toContain("private count"); + }); + + it("finds a Python def", async () => { + const testFile = join(testDir, "util.py"); + writeFileSync( + testFile, + ["def add(a, b):", " total = a + b", " return total", "", "", "def unrelated():", " pass", ""].join( + "\n", + ), + ); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-4", { name: "add" }); + const output = getTextOutput(result); + + expect(output).toContain("util.py:1: [function]"); + expect(output).toContain("def add(a, b):"); + expect(output).toContain("total = a + b"); + expect(output).toContain("return total"); + expect(output).not.toContain("unrelated"); + }); + + it("labels an indented Python def as a method", async () => { + const testFile = join(testDir, "cls.py"); + writeFileSync(testFile, ["class Foo:", " def bar(self):", " return 1", ""].join("\n")); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-5", { name: "bar" }); + const output = getTextOutput(result); + + expect(output).toContain("cls.py:2: [method]"); + expect(output).toContain("return 1"); + }); + + it("uses parsed declaration ranges around template literals", async () => { + const testFile = join(testDir, "template.ts"); + const templateExpression = `\${value}`; + writeFileSync( + testFile, + [ + "class Formatter {", + " private unrelated = 0;", + " format(value: string) {", + ` return \`value: ${templateExpression}\`;`, + " }", + "}", + ].join("\n"), + ); + + const tool = createSymbolTool(testDir); + const output = getTextOutput(await tool.execute("call-template", { name: "format" })); + + expect(output).toContain(`return \`value: ${templateExpression}\`;`); + expect(output).not.toContain("private unrelated"); + }); + + it("returns a clear empty result (not an error) for a nonexistent symbol", async () => { + const testFile = join(testDir, "empty.ts"); + writeFileSync(testFile, "export function realFunction() {}\n"); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-6", { name: "doesNotExist" }); + const output = getTextOutput(result); + + expect(output).toBe("No symbol named 'doesNotExist' found"); + expect(result.details).toBeUndefined(); + }); + + it("respects path scoping to a subdirectory", async () => { + const includedDir = join(testDir, "included"); + const excludedDir = join(testDir, "excluded"); + mkdirSync(includedDir, { recursive: true }); + mkdirSync(excludedDir, { recursive: true }); + writeFileSync(join(includedDir, "a.ts"), "export function scoped() {\n return 1;\n}\n"); + writeFileSync(join(excludedDir, "b.ts"), "export function scoped() {\n return 2;\n}\n"); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-7", { name: "scoped", path: "included" }); + const output = getTextOutput(result); + + expect(output).toContain("a.ts:1: [function]"); + expect(output).not.toContain("b.ts"); + expect(output).not.toContain("return 2;"); + }); + + it("truncates and shows a notice when results exceed the cap", async () => { + const dir = join(testDir, "many"); + mkdirSync(dir, { recursive: true }); + for (let i = 0; i < 5; i++) { + writeFileSync(join(dir, `file${i}.ts`), "export function dup() {\n return 1;\n}\n"); + } + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-8", { name: "dup", path: "many", limit: 2 }); + const output = getTextOutput(result); + const occurrences = output.match(/\[function\]/g) ?? []; + + expect(occurrences.length).toBe(2); + expect(output).toContain("[2 matches limit reached. Use limit=4 for more, or narrow path]"); + expect(result.details?.matchLimitReached).toBe(2); + }); + + it("silently skips files with unrecognized extensions", async () => { + writeFileSync(join(testDir, "notes.txt"), "function weirdMatch() {}\n"); + writeFileSync(join(testDir, "data.json"), '{"weirdMatch": "function weirdMatch() {}"}\n'); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-9", { name: "weirdMatch" }); + const output = getTextOutput(result); + + expect(output).toBe("No symbol named 'weirdMatch' found"); + }); + + it("respects .gitignore when walking a directory", async () => { + writeFileSync(join(testDir, ".gitignore"), "ignored.ts\n"); + writeFileSync(join(testDir, "ignored.ts"), "export function fromIgnored() {\n return 1;\n}\n"); + writeFileSync(join(testDir, "kept.ts"), "export function fromKept() {\n return 1;\n}\n"); + + const tool = createSymbolTool(testDir); + const ignoredResult = await tool.execute("call-10", { name: "fromIgnored" }); + expect(getTextOutput(ignoredResult)).toBe("No symbol named 'fromIgnored' found"); + + const keptResult = await tool.execute("call-11", { name: "fromKept" }); + expect(getTextOutput(keptResult)).toContain("kept.ts:1: [function]"); + }); + + it("applies nested .gitignore rules, including a subdirectory negation overriding a root-level pattern", async () => { + const subDir = join(testDir, "sub"); + mkdirSync(subDir, { recursive: true }); + writeFileSync(join(testDir, ".gitignore"), "*.tmp.ts\n"); + writeFileSync(join(subDir, ".gitignore"), "!local.tmp.ts\n"); + writeFileSync(join(subDir, "a.tmp.ts"), "export function tmpFn() {\n return 1;\n}\n"); + writeFileSync(join(subDir, "local.tmp.ts"), "export function localFn() {\n return 1;\n}\n"); + + const tool = createSymbolTool(testDir); + const ignoredResult = await tool.execute("call-nested-1", { name: "tmpFn" }); + expect(getTextOutput(ignoredResult)).toBe("No symbol named 'tmpFn' found"); + + const keptResult = await tool.execute("call-nested-2", { name: "localFn" }); + expect(getTextOutput(keptResult)).toContain("local.tmp.ts:1: [function]"); + }); + + it("skips directory symlinks by default, so a symlink loop back to an ancestor does not hang or crash", async () => { + writeFileSync(join(testDir, "real.ts"), "export function loopSafe() {\n return 1;\n}\n"); + const nestedDir = join(testDir, "a"); + mkdirSync(nestedDir, { recursive: true }); + symlinkSync(testDir, join(nestedDir, "loop"), "dir"); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-symlink", { name: "loopSafe" }); + const output = getTextOutput(result); + + expect(output).toContain("real.ts:1: [function]"); + }); + + it("follows a symlink to a regular file", async () => { + writeFileSync(join(testDir, "target.ts"), "export function viaSymlink() {\n return 1;\n}\n"); + symlinkSync(join(testDir, "target.ts"), join(testDir, "link.ts"), "file"); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-symlink-file", { name: "viaSymlink" }); + const output = getTextOutput(result); + + expect(output).toContain("[function]"); + }); + + it("skips files larger than the default 2MB per-file cap", async () => { + const largeFile = join(testDir, "large.ts"); + const padding = "// filler\n".repeat(250_000); // well over 2MB + writeFileSync(largeFile, `${padding}export function inLargeFile() {\n return 1;\n}\n`); + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-large", { name: "inLargeFile" }); + expect(getTextOutput(result)).toBe("No symbol named 'inLargeFile' found"); + }); + + it("honors a configurable maxFileSizeBytes option", async () => { + const smallButOverLimit = join(testDir, "small.ts"); + writeFileSync(smallButOverLimit, "export function overConfiguredLimit() {\n return 1;\n}\n"); + + const tool = createSymbolTool(testDir, { maxFileSizeBytes: 10 }); + const result = await tool.execute("call-configured-size", { name: "overConfiguredLimit" }); + expect(getTextOutput(result)).toBe("No symbol named 'overConfiguredLimit' found"); + }); + + it("caps a requested limit above 500 at the hard maximum and reports the cap explicitly", async () => { + const dir = join(testDir, "lots"); + mkdirSync(dir, { recursive: true }); + const fileCount = 510; + for (let i = 0; i < fileCount; i++) { + writeFileSync(join(dir, `file${i}.ts`), "export function manyDup() {\n return 1;\n}\n"); + } + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-above-500", { name: "manyDup", path: "lots", limit: 1000 }); + const output = getTextOutput(result); + const occurrences = output.match(/\[function\]/g) ?? []; + + expect(occurrences.length).toBe(500); + expect(output).toContain("Requested limit 1000 exceeds the maximum of 500; capped to 500"); + expect(output).toContain("500 matches limit reached (maximum supported)"); + expect(result.details?.requestedLimitCapped).toBe(500); + expect(result.details?.matchLimitReached).toBe(500); + }, 20000); + + it("stops scanning once one more match than the (default) limit is found, rather than scanning up to the hard cap", async () => { + const dir = join(testDir, "some"); + mkdirSync(dir, { recursive: true }); + for (let i = 0; i < 30; i++) { + writeFileSync(join(dir, `file${i}.ts`), "export function fewDup() {\n return 1;\n}\n"); + } + + const tool = createSymbolTool(testDir); + const result = await tool.execute("call-default-limit", { name: "fewDup", path: "some" }); + const output = getTextOutput(result); + const occurrences = output.match(/\[function\]/g) ?? []; + + // Default limit is 20; only 21 matches need to be found to know the result is truncated, + // so results should stop at the default limit even though 30 matches exist. + expect(occurrences.length).toBe(20); + expect(result.details?.matchLimitReached).toBe(20); + expect(output).toContain("Use limit=40 for more"); + }); +}); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 63b7f626064..0b4be7de552 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -471,6 +471,75 @@ describe("Coding Agent Tools", () => { }); }); + describe("edit tool syntax guardrail", () => { + it("should not produce a syntaxWarning for a normal, valid edit to a .ts file", async () => { + const testFile = join(testDir, "guardrail-valid.ts"); + writeFileSync(testFile, "function greet(name: string): string {\n\treturn 'hi ' + name;\n}\n"); + + const result = await editTool.execute("guardrail-1", { + path: testFile, + edits: [{ oldText: "hi", newText: "hello" }], + }); + + expect(result.details?.syntaxWarning).toBeUndefined(); + expect(getTextOutput(result)).not.toContain("Warning"); + }); + + it("should produce a syntaxWarning when an edit leaves unbalanced braces in a .ts file", async () => { + const testFile = join(testDir, "guardrail-unbalanced.ts"); + writeFileSync(testFile, "function greet() {\n\treturn 1;\n}\n"); + + const result = await editTool.execute("guardrail-2", { + path: testFile, + edits: [{ oldText: "function greet() {\n", newText: "function greet() {\n\tif (true) {\n" }], + }); + + expect(result.details?.syntaxWarning).toBeDefined(); + expect(getTextOutput(result)).toContain("[Warning: edited file may have a syntax issue"); + }); + + it("should produce a syntaxWarning with a parse-error message when an edit leaves invalid JSON", async () => { + const testFile = join(testDir, "guardrail.json"); + writeFileSync(testFile, '{\n\t"a": 1\n}\n'); + + const result = await editTool.execute("guardrail-3", { + path: testFile, + edits: [{ oldText: '"a": 1', newText: '"a": 1,' }], + }); + + expect(result.details?.syntaxWarning).toBeDefined(); + expect(result.details?.syntaxWarning?.length).toBeGreaterThan(0); + expect(getTextOutput(result)).toContain("[Warning: edited file may have a syntax issue"); + }); + + it("should never produce a syntaxWarning for file types with no checker (e.g. .md), even with mismatched braces", async () => { + const testFile = join(testDir, "guardrail.md"); + writeFileSync(testFile, "# Notes\n\nSome prose here.\n"); + + const result = await editTool.execute("guardrail-4", { + path: testFile, + edits: [{ oldText: "Some prose here.", newText: "Some prose with a { stray brace." }], + }); + + expect(result.details?.syntaxWarning).toBeUndefined(); + expect(getTextOutput(result)).not.toContain("Warning"); + }); + + it("should not false-positive on a well-formed string containing a brace character", async () => { + const testFile = join(testDir, "guardrail-brace-in-string.ts"); + writeFileSync(testFile, "const s = 'placeholder';\n"); + + const result = await editTool.execute("guardrail-5", { + path: testFile, + edits: [{ oldText: "'placeholder'", newText: '"{"' }], + }); + + expect(result.details?.syntaxWarning).toBeUndefined(); + expect(getTextOutput(result)).not.toContain("Warning"); + expect(readFileSync(testFile, "utf-8")).toBe('const s = "{";\n'); + }); + }); + describe("bash tool", () => { it("should execute simple commands", async () => { const result = await bashTool.execute("test-call-8", { command: "echo 'test output'" }); diff --git a/packages/coding-agent/test/tree-sitter-assets.test.ts b/packages/coding-agent/test/tree-sitter-assets.test.ts new file mode 100644 index 00000000000..952fcb1773b --- /dev/null +++ b/packages/coding-agent/test/tree-sitter-assets.test.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +describe("Tree-sitter asset copy", () => { + const outputDirectories: string[] = []; + + afterEach(() => { + for (const outputDirectory of outputDirectories.splice(0)) + rmSync(outputDirectory, { recursive: true, force: true }); + }); + + it("copies exactly the runtime, manifest, grammars, and project-owned queries", () => { + const outputDirectory = mkdtempSync(join(tmpdir(), "coding-agent-tree-sitter-assets-")); + outputDirectories.push(outputDirectory); + execFileSync(process.execPath, ["scripts/copy-tree-sitter-assets.mjs", outputDirectory], { + cwd: join(process.cwd()), + }); + + expect(existsSync(join(outputDirectory, "manifest.json"))).toBe(true); + expect(existsSync(join(outputDirectory, "web-tree-sitter.wasm"))).toBe(true); + for (const grammar of ["csharp", "go", "java", "javascript", "python", "rust", "tsx", "typescript"]) { + expect(existsSync(join(outputDirectory, "queries", `${grammar}.scm`)), grammar).toBe(true); + } + expect(readFileSync(join(outputDirectory, "manifest.json"), "utf8")).toContain('"tree-sitter-csharp.wasm"'); + expect(existsSync(join(outputDirectory, "grammars", "index.js"))).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/tree-sitter-navigation.test.ts b/packages/coding-agent/test/tree-sitter-navigation.test.ts new file mode 100644 index 00000000000..30dcb9113d5 --- /dev/null +++ b/packages/coding-agent/test/tree-sitter-navigation.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { findDeclarations } from "../src/core/tools/tree-sitter-navigation.ts"; + +describe("Tree-sitter navigation", () => { + it("extracts declarations from every supported grammar", async () => { + const fixtures = [ + ["example.js", "export class Café { run() {} }\nexport function load() {}", ["Café", "run", "load"]], + ["example.ts", "export interface Item {}\nexport const load = () => 1", ["Item", "load"]], + ["example.tsx", "export const View = () =>
", ["View"]], + ["example.py", "class Animal:\n def speak(self):\n return 'noise'\n", ["Animal", "speak"]], + ["example.go", "type Point struct{}\nfunc (p Point) Move() {}", ["Point", "Move"]], + ["example.rs", "struct Point;\nimpl Point { fn move_to(&self) {} }", ["Point", "Point", "move_to"]], + ["Example.java", "class Example { Example() {} void run() {} }", ["Example", "Example", "run"]], + [ + "Example.cs", + "namespace Demo { class Example { Example() {} void Run() {} } }", + ["Example", "Example", "Run"], + ], + ] as const; + + for (const [filePath, source, expectedNames] of fixtures) { + const declarations = await findDeclarations(filePath, source); + expect( + declarations?.map((declaration) => declaration.name), + filePath, + ).toEqual(expectedNames); + } + }); + + it("uses JavaScript string offsets for Unicode and CRLF source", async () => { + const source = "// héllo\r\nexport function café() {\r\n return 'ok';\r\n}\r\n"; + const declarations = await findDeclarations("example.ts", source); + const declaration = declarations?.[0]; + expect(declaration?.name).toBe("café"); + expect(declaration?.startLine).toBe(2); + expect(source.slice(declaration?.startIndex, declaration?.endIndex)).toBe( + "export function café() {\r\n return 'ok';\r\n}", + ); + }); + + it("keeps declarations before a syntax error", async () => { + const declarations = await findDeclarations("example.ts", "export function valid() {}\nconst broken = {"); + expect(declarations?.map((declaration) => declaration.name)).toContain("valid"); + }); + + it("keeps every declaration captured by a shared container node", async () => { + const fixtures = [ + ["example.go", "type (\n\tA int\n\tB string\n)\n", ["A", "B"]], + ["example.js", "const a = () => 1, b = () => 2;\n", ["a", "b"]], + ] as const; + + for (const [filePath, source, expectedNames] of fixtures) { + const declarations = await findDeclarations(filePath, source); + expect( + declarations?.map((declaration) => declaration.name), + filePath, + ).toEqual(expectedNames); + } + }); + + it("returns undefined for unsupported extensions", async () => { + expect(await findDeclarations("notes.md", "# Notes")).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index 3c49c145df8..00dc0dbffbf 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -34,6 +34,25 @@ export default mergeConfig( { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: workspaceSourcePaths.aiOAuth }, { find: /^@mariozechner\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex }, { find: /^@mariozechner\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex }, + // The @cheetahbyte/* packages ship built dist/ output; when running tests + // against a worktree that hasn't been built yet, resolve straight to source + // so `npm test` doesn't require a full `npm run build` first (mirrors the + // @earendil-works/@mariozechner aliases above). + { + find: /^@cheetahbyte\/pi-client$/, + replacement: fileURLToPath(new URL("../client/src/index.ts", import.meta.url)), + }, + { + find: /^@cheetahbyte\/pi-protocol$/, + replacement: fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)), + }, + { find: /^@cheetahbyte\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex }, + { + find: /^@cheetahbyte\/pi-ai\/(.+)$/, + replacement: `${fileURLToPath(new URL("../ai/src/", import.meta.url))}$1.ts`, + }, + { find: /^@cheetahbyte\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex }, + { find: /^@cheetahbyte\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex }, ], }, }), diff --git a/packages/evals/vitest.config.ts b/packages/evals/vitest.config.ts index 67dad900bb6..8e780a352fa 100644 --- a/packages/evals/vitest.config.ts +++ b/packages/evals/vitest.config.ts @@ -14,7 +14,7 @@ export default mergeConfig( reporters: ["vitest-evals/reporter", "./src/vitest-evals/reporter.ts"], }, resolve: { - alias: [{ find: /^@earendil-works\/pi-coding-agent$/, replacement: workspaceSourcePaths.codingAgentIndex }], + alias: [{ find: /^@cheetahbyte\/pi-coding-agent$/, replacement: workspaceSourcePaths.codingAgentIndex }], }, }), ); diff --git a/packages/evals/vitest.test.config.ts b/packages/evals/vitest.test.config.ts index 20aa79309ef..02aee3dbba8 100644 --- a/packages/evals/vitest.test.config.ts +++ b/packages/evals/vitest.test.config.ts @@ -8,7 +8,7 @@ export default mergeConfig( include: ["test/**/*.test.ts"], }, resolve: { - alias: [{ find: /^@earendil-works\/pi-coding-agent$/, replacement: workspaceSourcePaths.codingAgentIndex }], + alias: [{ find: /^@cheetahbyte\/pi-coding-agent$/, replacement: workspaceSourcePaths.codingAgentIndex }], }, }), ); diff --git a/packages/server/README.md b/packages/server/README.md index bd5a8220d56..0775d1c2317 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,18 +1,12 @@ # @cheetahbyte/pi-server -Experimental. This package is under active development and may change or be removed without notice. Its CLI, APIs, and behavior are not yet stable. +Experimental. This package is under active development and may change or be removed without notice. Its APIs and behavior are not yet stable. Server package for pi. -## CLI - -```bash -server --help -``` - ## Session server core -The package also exports the new `PiServer` session server. This API is additive while the legacy child-process supervisor and `server` CLI are migrated. +The package exports the `PiServer` session server. ```ts import type { PiSessionBackend } from "@cheetahbyte/pi-server"; @@ -39,7 +33,9 @@ const server = createUnixServer(backend, { await server.start(); ``` -`PiServer` composes transport listeners through the `PiServerListener` interface. Each listener must complete any transport-specific authentication and authorization before passing a connection to `PiServer`. For example, a WebSocket listener can validate credentials during the HTTP upgrade, while the Unix listener relies on socket filesystem permissions. The Unix submodule exports the `createUnixListener()` building block and `createUnixServer()` preset, keeping the common case concise without coupling the primary server to Unix sockets. The listener uses length-prefixed CBOR messages from `@cheetahbyte/pi-protocol`. It does not yet replace the legacy JSONL IPC control plane, child-process supervisor, standalone `server` CLI, or Radius presence integration. +`PiServer` composes transport listeners through the `PiServerListener` interface. Each listener must complete any transport-specific authentication and authorization before passing a connection to `PiServer`. For example, a WebSocket listener can validate credentials during the HTTP upgrade, while the Unix listener relies on socket filesystem permissions. The Unix submodule exports the `createUnixListener()` building block and `createUnixServer()` preset, keeping the common case concise without coupling the primary server to Unix sockets. The listener uses length-prefixed CBOR messages from `@cheetahbyte/pi-protocol`. + +This package does not provide a standalone CLI or coding-agent backend. Applications supply the `PiSessionBackend` implementation. ## Transport testing @@ -50,7 +46,3 @@ Custom transports can use `@cheetahbyte/pi-server/testing` for deterministic pro `@cheetahbyte/pi-ai` domain objects and `@cheetahbyte/pi-protocol` wire DTOs remain independent. This package owns their boundary and exports `toProtocolModelMetadata()`, `toProtocolAssistantMessage()`, `toProtocolUserMessage()`, and `toProtocolToolResultMessage()`. The adapters reject invalid tool inputs, identifiers, timestamps, and mismatched tool results; `toProtocolToolResultMessage()` requires the original `ToolCall` so it can verify the association and convert its arguments itself. Diagnostic details are explicitly sanitized. Closed `pi-ai` unions are mapped exhaustively, and compile-time field manifests enumerate current `pi-ai` properties so additions require an explicit review. The protocol mirrors `pi-ai` vocabulary such as `toolCall` and `toolUse` where the semantics are identical. Protocol schemas enforce consistent lifecycle states, and tests encode adapter output through the runtime schemas so incompatible changes fail in the bridging package. - -## Legacy server migration - -The existing IPC, supervisor, process management, persistence, and Radius modules remain available during migration. The new Unix session protocol supersedes the legacy socket framing and RPC proxy only after the coding-agent backend and CLI replacement have landed. Radius is presence and registration infrastructure, not a transport, and requires a separate integration with the new server lifecycle before the legacy supervisor can be removed. diff --git a/packages/server/package.json b/packages/server/package.json index 107df93189f..dece405815c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,15 +17,8 @@ "./unix": { "types": "./dist/transports/unix/index.d.ts", "import": "./dist/transports/unix/index.js" - }, - "./legacy": { - "types": "./dist/legacy/index.d.ts", - "import": "./dist/legacy/index.js" } }, - "bin": { - "server": "./dist/legacy/cli.js" - }, "files": [ "dist", "README.md", @@ -34,7 +27,7 @@ "scripts": { "clean": "shx rm -rf dist", "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", - "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/legacy/cli.js", + "build": "tsgo -p tsconfig.build.json", "test": "vitest --run", "typecheck": "tsgo -p tsconfig.test.json", "prepublishOnly": "npm run clean && npm run build" @@ -55,7 +48,6 @@ }, "dependencies": { "@cheetahbyte/pi-ai": "^0.83.0-cheetahbyte.3", - "@cheetahbyte/pi-coding-agent": "^0.83.0-cheetahbyte.3", "@cheetahbyte/pi-protocol": "^0.83.0-cheetahbyte.3" }, "devDependencies": { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 46625268aa5..de4b0d895ae 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,5 +1,4 @@ export * from "./errors.ts"; -export * from "./legacy/index.ts"; export * from "./listener.ts"; export * from "./protocol.ts"; export * from "./server.ts"; diff --git a/packages/server/src/legacy/cli.ts b/packages/server/src/legacy/cli.ts deleted file mode 100644 index d0e8a71998d..00000000000 --- a/packages/server/src/legacy/cli.ts +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { createConnection } from "node:net"; -import { dirname, join } from "node:path"; -import { cwd } from "node:process"; -import { fileURLToPath } from "node:url"; -import type { RpcCommand, RpcExtensionUIResponse } from "@cheetahbyte/pi-coding-agent"; -import { getSocketPath } from "./config.ts"; -import { sendIpcRequest } from "./ipc/client.ts"; -import { encodeMessage } from "./ipc/protocol.ts"; -import { serve } from "./serve.ts"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")) as { - version: string; -}; - -function printHelp(): void { - console.log( - `server v${packageJson.version}\n\nUsage:\n server serve\n server list\n server spawn [--cwd ] [--label