diff --git a/.changeset/btw-readonly-tools.md b/.changeset/btw-readonly-tools.md deleted file mode 100644 index 52e43d02e91..00000000000 --- a/.changeset/btw-readonly-tools.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Add read-only tools to the /btw side agent. diff --git a/.changeset/chat-scrollbar-target.md b/.changeset/chat-scrollbar-target.md deleted file mode 100644 index 129b048a691..00000000000 --- a/.changeset/chat-scrollbar-target.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: fix the conversation scrollbar being too thin to click and drag easily. diff --git a/.changeset/compact-tool-cards.md b/.changeset/compact-tool-cards.md deleted file mode 100644 index 563c0a78627..00000000000 --- a/.changeset/compact-tool-cards.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Collapse finished tool calls in the transcript to a header plus one marked outcome row: short output is shown whole, hidden output is counted (`N more lines`, `+N more`) and revealed by `Ctrl+O`, which the footer advertises while it is available. diff --git a/.changeset/compaction-resume-anchor.md b/.changeset/compaction-resume-anchor.md deleted file mode 100644 index 2daf11e9631..00000000000 --- a/.changeset/compaction-resume-anchor.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix the agent resuming the wrong request after automatic context compaction in long sessions. diff --git a/.changeset/database-config-section.md b/.changeset/database-config-section.md deleted file mode 100644 index 7aaa8aba894..00000000000 --- a/.changeset/database-config-section.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -The minidb session-index read model and global search worker are now always on; the experimental flags have been replaced by the `[database]` config section and the `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` / `KIMI_CODE_SEARCH_WORKER` env vars. diff --git a/.changeset/delete-session-from-picker.md b/.changeset/delete-session-from-picker.md new file mode 100644 index 00000000000..48e0b61b145 --- /dev/null +++ b/.changeset/delete-session-from-picker.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Delete sessions from the session picker: press Ctrl+X on a session, then y to confirm. diff --git a/.changeset/fix-anthropic-profile-undefined-model.md b/.changeset/fix-anthropic-profile-undefined-model.md deleted file mode 100644 index 5db03cac002..00000000000 --- a/.changeset/fix-anthropic-profile-undefined-model.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kosong": patch ---- - -Fix a crash when a model config entry lacks the `model` field (e.g. from a malformed TOML key like `[models.kimi-k2.7-code]`): the Anthropic profile matchers now tolerate `undefined` model names and return no profile instead of throwing `TypeError: Cannot read properties of undefined (reading 'toLowerCase')`. diff --git a/.changeset/fix-task-notification-order.md b/.changeset/fix-task-notification-order.md deleted file mode 100644 index ec1e22bf1e5..00000000000 --- a/.changeset/fix-task-notification-order.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: fix background task notifications lingering at the bottom of the session during subsequent tool calls. diff --git a/.changeset/glob-pagination.md b/.changeset/glob-pagination.md deleted file mode 100644 index d67f3026a2e..00000000000 --- a/.changeset/glob-pagination.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Allow file searches to retrieve matches beyond the first 100 results. diff --git a/.changeset/kimi-default-model-image-gate.md b/.changeset/kimi-default-model-image-gate.md deleted file mode 100644 index b20f2a55517..00000000000 --- a/.changeset/kimi-default-model-image-gate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Accept HEIC, HEIF, and BMP images on a session's first prompt when the configured default model is served by Kimi and no model has been selected yet. diff --git a/.changeset/kimi-heic-bmp-images.md b/.changeset/kimi-heic-bmp-images.md deleted file mode 100644 index cdaf0a8e0ea..00000000000 --- a/.changeset/kimi-heic-bmp-images.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Accept HEIC, HEIF, and BMP images in ReadMediaFile and prompt attachments when the model is served by Kimi. diff --git a/.changeset/lazy-task-output.md b/.changeset/lazy-task-output.md deleted file mode 100644 index 0cb55adfb3e..00000000000 --- a/.changeset/lazy-task-output.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: fix page jank caused by hundreds of simultaneous requests when reloading a session with many background tasks. diff --git a/.changeset/mcp-attachment-originals.md b/.changeset/mcp-attachment-originals.md new file mode 100644 index 00000000000..218a4fa8e44 --- /dev/null +++ b/.changeset/mcp-attachment-originals.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve MCP attachments that cannot be delivered directly to the model. diff --git a/.changeset/mcp-structured-results.md b/.changeset/mcp-structured-results.md deleted file mode 100644 index c086c48ff88..00000000000 --- a/.changeset/mcp-structured-results.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Preserve distinct structured data in MCP tool results. diff --git a/.changeset/native-zstd-manifest.md b/.changeset/native-zstd-manifest.md deleted file mode 100644 index a095885e9b7..00000000000 --- a/.changeset/native-zstd-manifest.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Support compressed downloads from updated native release manifests. diff --git a/.changeset/notify-user-panel.md b/.changeset/notify-user-panel.md deleted file mode 100644 index 4327583943a..00000000000 --- a/.changeset/notify-user-panel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Add an experimental Updates panel with paginated progress messages from the main agent and subagents; enable it with `KIMI_CODE_EXPERIMENTAL_NOTIFY_USER=1`. diff --git a/.changeset/offscreen-history-markdown.md b/.changeset/offscreen-history-markdown.md deleted file mode 100644 index 6b5471129cc..00000000000 --- a/.changeset/offscreen-history-markdown.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: reduce jank when opening and scrolling back through long conversations, while preserving message and tool expansion state. diff --git a/.changeset/pause-goal-clock-on-close.md b/.changeset/pause-goal-clock-on-close.md new file mode 100644 index 00000000000..a687aab79b1 --- /dev/null +++ b/.changeset/pause-goal-clock-on-close.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Exclude time spent with the session closed from goal time budgets. diff --git a/.changeset/preserve-media-attachment-names.md b/.changeset/preserve-media-attachment-names.md deleted file mode 100644 index 9b540316a76..00000000000 --- a/.changeset/preserve-media-attachment-names.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Preserve image and video filenames in session history. diff --git a/.changeset/rc-local-ui-token.md b/.changeset/rc-local-ui-token.md new file mode 100644 index 00000000000..4730554447e --- /dev/null +++ b/.changeset/rc-local-ui-token.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Include the server token in the Remote Control Local UI link so it opens already signed in. diff --git a/.changeset/rc-tunnel-base64-linear-check.md b/.changeset/rc-tunnel-base64-linear-check.md new file mode 100644 index 00000000000..c5f1cc79fce --- /dev/null +++ b/.changeset/rc-tunnel-base64-linear-check.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Remote Control uploads larger than ~3.5MB always failing with a 400 error. diff --git a/.changeset/rc-tunnel-cache.md b/.changeset/rc-tunnel-cache.md new file mode 100644 index 00000000000..737b2821ecd --- /dev/null +++ b/.changeset/rc-tunnel-cache.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reuse unchanged Remote Control assets across page loads instead of retransferring them. diff --git a/.changeset/read-character-budgets.md b/.changeset/read-character-budgets.md deleted file mode 100644 index fdac35e6075..00000000000 --- a/.changeset/read-character-budgets.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Add configurable character limits and resumable long-line file reads without repeated output truncation. diff --git a/.changeset/read-invalid-utf16.md b/.changeset/read-invalid-utf16.md deleted file mode 100644 index beea27d7e58..00000000000 --- a/.changeset/read-invalid-utf16.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Read malformed UTF-16 files with an explicit lossy-decoding warning. diff --git a/.changeset/read-tail-consistency.md b/.changeset/read-tail-consistency.md deleted file mode 100644 index 4c225e2f6b8..00000000000 --- a/.changeset/read-tail-consistency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Avoid repeated scanning for common tail reads and report file changes detected during tail rereads. diff --git a/.changeset/recommended-default-effort.md b/.changeset/recommended-default-effort.md deleted file mode 100644 index 6a2f4fc1ff4..00000000000 --- a/.changeset/recommended-default-effort.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Upgrade the default thinking effort to the recommended level for eligible users. diff --git a/.changeset/remote-control-always-available.md b/.changeset/remote-control-always-available.md deleted file mode 100644 index 43737693b4f..00000000000 --- a/.changeset/remote-control-always-available.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Remote Control is now always on; the experimental `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL` flag has been removed. diff --git a/.changeset/remove-goal-time-cap.md b/.changeset/remove-goal-time-cap.md new file mode 100644 index 00000000000..57920efbf9b --- /dev/null +++ b/.changeset/remove-goal-time-cap.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Remove the 24-hour limit on goal time budgets. diff --git a/.changeset/rm-rf-temp-paths.md b/.changeset/rm-rf-temp-paths.md new file mode 100644 index 00000000000..9ed41bef33d --- /dev/null +++ b/.changeset/rm-rf-temp-paths.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Skip the confirmation prompt for rm -rf commands that target only /tmp or /temp paths. diff --git a/.changeset/secondary-model-always-on.md b/.changeset/secondary-model-always-on.md deleted file mode 100644 index 7655c14b688..00000000000 --- a/.changeset/secondary-model-always-on.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -The subagent model pool (`[secondary_model]`) is now always on; the experimental secondary-model flag and the `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` opt-out have been removed. diff --git a/.changeset/session-delete-context-menu.md b/.changeset/session-delete-context-menu.md deleted file mode 100644 index 64d5ea9175b..00000000000 --- a/.changeset/session-delete-context-menu.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -web: support permanently deleting sessions from the session row context menu, with a confirmation prompt. diff --git a/.changeset/session-index-stray-files.md b/.changeset/session-index-stray-files.md deleted file mode 100644 index 2077fcd02db..00000000000 --- a/.changeset/session-index-stray-files.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix recent sessions missing from the session list when the sessions directory contains stray files. diff --git a/.changeset/skill-reload-on-turn-end.md b/.changeset/skill-reload-on-turn-end.md deleted file mode 100644 index d03f95ae3fc..00000000000 --- a/.changeset/skill-reload-on-turn-end.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: fix skills created mid-session not appearing in the slash list until the app restarts. diff --git a/.changeset/stable-streaming-markdown.md b/.changeset/stable-streaming-markdown.md deleted file mode 100644 index fb2bad14424..00000000000 --- a/.changeset/stable-streaming-markdown.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: fix trailing backticks briefly flashing at the end of code blocks while they stream. diff --git a/.changeset/steer-background-waits.md b/.changeset/steer-background-waits.md new file mode 100644 index 00000000000..a72f764f97a --- /dev/null +++ b/.changeset/steer-background-waits.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow steering messages to interrupt waits for background tasks. diff --git a/.changeset/stop-context-budget-reminders.md b/.changeset/stop-context-budget-reminders.md deleted file mode 100644 index 3bbd7752e2e..00000000000 --- a/.changeset/stop-context-budget-reminders.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Stop reminding the model of its context budget before automatic compaction. diff --git a/.changeset/tasks-list-agent-model.md b/.changeset/tasks-list-agent-model.md deleted file mode 100644 index 62dfaeef21d..00000000000 --- a/.changeset/tasks-list-agent-model.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@moonshot-ai/kimi-code': patch ---- - -The /tasks panel now shows each background agent's model under its task row. diff --git a/.changeset/tidy-donuts-serve.md b/.changeset/tidy-donuts-serve.md deleted file mode 100644 index e135ea37440..00000000000 --- a/.changeset/tidy-donuts-serve.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix the streaming debug timing attributing client-side busy time to the server. diff --git a/.changeset/tower-mode-fixes.md b/.changeset/tower-mode-fixes.md deleted file mode 100644 index 5e280a7f51b..00000000000 --- a/.changeset/tower-mode-fixes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Tower worker and reviewer briefings now carry the full mission context, and tower agent timeouts follow the subagent timeout setting (`[subagent] timeout_ms` or `KIMI_SUBAGENT_TIMEOUT_MS`), defaulting to 2 hours. Fix the /tasks list not showing the model for tower-spawned agents. diff --git a/.changeset/tower-resume-wake-teardown.md b/.changeset/tower-resume-wake-teardown.md new file mode 100644 index 00000000000..756532dbc6a --- /dev/null +++ b/.changeset/tower-resume-wake-teardown.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Tower mode reliability fixes across messaging, worktrees, and the review-to-merge gate. diff --git a/.changeset/tunnel-response-gzip.md b/.changeset/tunnel-response-gzip.md new file mode 100644 index 00000000000..593c163d637 --- /dev/null +++ b/.changeset/tunnel-response-gzip.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Compress Remote Control tunnel responses with gzip. diff --git a/.changeset/update-yes-flag.md b/.changeset/update-yes-flag.md new file mode 100644 index 00000000000..0567b158387 --- /dev/null +++ b/.changeset/update-yes-flag.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add `-y, --yes` to `kimi upgrade` (alias `kimi update`) to skip the confirmation prompt and install the update directly. diff --git a/.changeset/visible-media-attachments.md b/.changeset/visible-media-attachments.md deleted file mode 100644 index aa23a1d0bca..00000000000 --- a/.changeset/visible-media-attachments.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -web: preview images and videos in a reorderable media rail in the composer, mention them in the text on demand, and keep the previews after queueing and sending. diff --git a/.changeset/warn-malformed-models-entry.md b/.changeset/warn-malformed-models-entry.md new file mode 100644 index 00000000000..0b74fb051cd --- /dev/null +++ b/.changeset/warn-malformed-models-entry.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Warn at startup when a [models] entry in config.toml is missing the model field and cannot be used. diff --git a/.changeset/warn-trust-gated-mcp-in-print-mode.md b/.changeset/warn-trust-gated-mcp-in-print-mode.md deleted file mode 100644 index a0b1fb79758..00000000000 --- a/.changeset/warn-trust-gated-mcp-in-print-mode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Print a warning in `kimi -p` when project-level MCP servers are skipped because the folder is not trusted. diff --git a/.changeset/watch-user-skill-roots.md b/.changeset/watch-user-skill-roots.md deleted file mode 100644 index ff85d285a6d..00000000000 --- a/.changeset/watch-user-skill-roots.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Watch the user-level skill roots (`~/.kimi-code/skills` and `~/.agents/skills`) so the workspace skill catalog refreshes automatically when skills are created, modified, or deleted while the daemon is running — no restart or manual reload needed. diff --git a/.changeset/web-open-wildcard-host.md b/.changeset/web-open-wildcard-host.md deleted file mode 100644 index bf08cc40289..00000000000 --- a/.changeset/web-open-wildcard-host.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Open the browser on localhost instead of the wildcard bind address for `kimi web --host 0.0.0.0`. diff --git a/.changeset/witty-crabs-tap.md b/.changeset/witty-crabs-tap.md deleted file mode 100644 index 384d1ed9128..00000000000 --- a/.changeset/witty-crabs-tap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix slow response streaming and rendering after resuming sessions with many scheduled cron turns. diff --git a/.specify/extensions/gym/scripts/drop.mjs b/.specify/extensions/gym/scripts/drop.mjs index db4a7c41b8d..48e085fb5b3 100644 --- a/.specify/extensions/gym/scripts/drop.mjs +++ b/.specify/extensions/gym/scripts/drop.mjs @@ -8,12 +8,11 @@ // node drop.mjs --agent --where "GYM ex-1" \ // --did "..." --expected "..." --happened "..." import fs from 'node:fs/promises'; -import { existsSync, readFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const __dirname = import.meta.dirname; const EXT_DIR = path.resolve(__dirname, '..'); const CONFIG_PATH = path.join(EXT_DIR, 'gym-config.yml'); const DEFAULTS = { drop_stores: ['local', 'ledger'] }; @@ -80,7 +79,7 @@ function parseArgs(argv) { async function main() { const cfg = loadConfig(); - const stores = Array.isArray(cfg.drop_stores) && cfg.drop_stores.length ? cfg.drop_stores : ['local', 'ledger']; + const stores = Array.isArray(cfg.drop_stores) && cfg.drop_stores.length > 0 ? cfg.drop_stores : ['local', 'ledger']; const o = parseArgs(process.argv.slice(2)); const repoRoot = process.cwd(); const date = new Date().toISOString().slice(0, 10); @@ -120,7 +119,7 @@ async function main() { console.log('Drop card recorded:'); written.forEach((p) => console.log(' + ' + p)); const missing = ['agent', 'where', 'did', 'expected', 'happened'].filter((k) => !o[k]); - if (missing.length) console.log(`Note: missing fields flagged: ${missing.join(', ')}`); + if (missing.length > 0) console.log(`Note: missing fields flagged: ${missing.join(', ')}`); } -main().catch((e) => { console.error(e); process.exit(1); }); +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/.specify/extensions/gym/scripts/gym-runner.mjs b/.specify/extensions/gym/scripts/gym-runner.mjs index 7448e57b4be..0924bf95c6c 100644 --- a/.specify/extensions/gym/scripts/gym-runner.mjs +++ b/.specify/extensions/gym/scripts/gym-runner.mjs @@ -15,9 +15,8 @@ import fs from 'node:fs/promises'; import { existsSync, readFileSync } from 'node:fs'; import { globSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const __dirname = import.meta.dirname; const EXT_DIR = path.resolve(__dirname, '..'); // .specify/extensions/gym const CONFIG_PATH = path.join(EXT_DIR, 'gym-config.yml'); const DEFAULTS = { @@ -104,13 +103,13 @@ const ctx = { async function loadModules(dir) { const mods = []; if (!existsSync(dir)) return mods; - const files = globSync('*.mjs', { cwd: dir }).sort(); + const files = globSync('*.mjs', { cwd: dir }).toSorted(); for (const f of files) { try { const mod = await import(path.join(dir, f)); if (mod.default) mods.push({ file: f, ...mod.default }); - } catch (e) { - console.log(` SKIP ${f} — ${e.message}`); + } catch (error) { + console.log(` SKIP ${f} — ${error.message}`); } } return mods; @@ -135,7 +134,7 @@ async function waitForSubmit(sandbox, timeoutSec) { setTimeout(tick, 2000); } }; - tick(); + void tick(); }); } @@ -163,8 +162,8 @@ async function runWorkout(warmupOnly) { const res = await ex.verify(ctx); if (res && res.ok) { console.log(' GROWN\n'); grown++; } else { console.log(` WEAK — ${res?.note || 'verify returned false'}\n`); } - } catch (e) { - console.log(` WEAK — ${e.message}\n`); + } catch (error) { + console.log(` WEAK — ${error.message}\n`); } } console.log(`BOARD: ${grown}/${reps.length} muscles grown.\n`); @@ -185,8 +184,8 @@ async function runWorkout(warmupOnly) { const r = await ex.evaluate(SANDBOX); if (r && r.pass) { console.log(` PASSED — ${r.notes || ''}\n`); passed++; } else { console.log(` FAILED — ${r?.notes || 'evaluate returned false'}\n`); } - } catch (e) { - console.log(` FAILED — ${e.message}\n`); + } catch (error) { + console.log(` FAILED — ${error.message}\n`); } } console.log(`EXERCISES: ${passed}/${exercises.length} passed.`); @@ -257,4 +256,4 @@ async function main() { process.exit(r.gate === 'open' ? 0 : 1); } -main().catch((e) => { console.error(e); process.exit(1); }); +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 6e305fcb975..d9820860722 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,79 @@ # @moonshot-ai/kimi-code +## 0.42.0 + +### Minor Changes + +- [#3613](https://github.com/MoonshotAI/kimi-code/pull/3613) [`d4d20d2`](https://github.com/MoonshotAI/kimi-code/commit/d4d20d21d733a7942056c15ffcc2c74cce51f7d5) Thanks [@liukx0205](https://github.com/liukx0205)! - Add read-only tools to the /btw side agent. + +- [#3524](https://github.com/MoonshotAI/kimi-code/pull/3524) [`f6a9c39`](https://github.com/MoonshotAI/kimi-code/commit/f6a9c39e22a63b2a231684ed8426bfe4ec406cf7) Thanks [@RealKai42](https://github.com/RealKai42)! - Add an experimental Updates panel with paginated progress messages from the main agent and subagents; enable it with `KIMI_CODE_EXPERIMENTAL_NOTIFY_USER=1`. + +- [#3634](https://github.com/MoonshotAI/kimi-code/pull/3634) [`e831fd1`](https://github.com/MoonshotAI/kimi-code/commit/e831fd1ea9488ad5192bcc9d96579470cf0c4442) Thanks [@7Sageer](https://github.com/7Sageer)! - The subagent model pool (`[secondary_model]`) is now always on; the experimental secondary-model flag and the `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` opt-out have been removed. + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: support permanently deleting sessions from the session row context menu, with a confirmation prompt. + +- [#3526](https://github.com/MoonshotAI/kimi-code/pull/3526) [`55685c5`](https://github.com/MoonshotAI/kimi-code/commit/55685c58b5dbff692e16c66efa65fa6d32222f61) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop reminding the model of its context budget before automatic compaction. + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: preview images and videos in a reorderable media rail in the composer, mention them in the text on demand, and keep the previews after queueing and sending. + +### Patch Changes + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: fix the conversation scrollbar being too thin to click and drag easily. + +- [#3539](https://github.com/MoonshotAI/kimi-code/pull/3539) [`34ad513`](https://github.com/MoonshotAI/kimi-code/commit/34ad5137ce36f6eb03c4a42145e9bdcc8d607d87) Thanks [@RealKai42](https://github.com/RealKai42)! - Collapse finished tool calls in the transcript to a header plus one marked outcome row: short output is shown whole, hidden output is counted (`N more lines`, `+N more`) and revealed by `Ctrl+O`, which the footer advertises while it is available. + +- [#3537](https://github.com/MoonshotAI/kimi-code/pull/3537) [`f12d59e`](https://github.com/MoonshotAI/kimi-code/commit/f12d59e089e2531a33fbca30b26ffeabd5862b45) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix the agent resuming the wrong request after automatic context compaction in long sessions. + +- [#3552](https://github.com/MoonshotAI/kimi-code/pull/3552) [`f0434f2`](https://github.com/MoonshotAI/kimi-code/commit/f0434f2d25ead11d44f5616930157a59dfd524a5) Thanks [@sailist](https://github.com/sailist)! - The minidb session-index read model and global search worker are now always on; the experimental flags have been replaced by the `[database]` config section and the `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` / `KIMI_CODE_SEARCH_WORKER` env vars. + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: fix background task notifications lingering at the bottom of the session during subsequent tool calls. + +- [#3658](https://github.com/MoonshotAI/kimi-code/pull/3658) [`ff7371b`](https://github.com/MoonshotAI/kimi-code/commit/ff7371b70a7ae80ec70712b610f73e6d6f29edc0) Thanks [@RealKai42](https://github.com/RealKai42)! - Allow file searches to retrieve matches beyond the first 100 results. + +- [#3652](https://github.com/MoonshotAI/kimi-code/pull/3652) [`7f5debf`](https://github.com/MoonshotAI/kimi-code/commit/7f5debfa71ac9e4a23b5dab1a511aa3672677381) Thanks [@RealKai42](https://github.com/RealKai42)! - Accept HEIC, HEIF, and BMP images on a session's first prompt when the configured default model is served by Kimi and no model has been selected yet. + +- [#3649](https://github.com/MoonshotAI/kimi-code/pull/3649) [`80480c0`](https://github.com/MoonshotAI/kimi-code/commit/80480c01d27a921d4660ad043e78c8b61167f82b) Thanks [@RealKai42](https://github.com/RealKai42)! - Accept HEIC, HEIF, and BMP images in ReadMediaFile and prompt attachments when the model is served by Kimi. + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: fix page jank caused by hundreds of simultaneous requests when reloading a session with many background tasks. + +- [#3654](https://github.com/MoonshotAI/kimi-code/pull/3654) [`6de0cec`](https://github.com/MoonshotAI/kimi-code/commit/6de0cec174ae3af44fc9f9f74b2aa8599b928fdf) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve distinct structured data in MCP tool results. + +- [#3669](https://github.com/MoonshotAI/kimi-code/pull/3669) [`f8c606e`](https://github.com/MoonshotAI/kimi-code/commit/f8c606e7d7b33a43190721d47ec6c8a2076eaec6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Support compressed downloads from updated native release manifests. + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: reduce jank when opening and scrolling back through long conversations, while preserving message and tool expansion state. + +- [#3548](https://github.com/MoonshotAI/kimi-code/pull/3548) [`baf17a8`](https://github.com/MoonshotAI/kimi-code/commit/baf17a8fcc289f20fa6c8d85dd8f93eeb3ff0cbc) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve image and video filenames in session history. + +- [#3645](https://github.com/MoonshotAI/kimi-code/pull/3645) [`5000f98`](https://github.com/MoonshotAI/kimi-code/commit/5000f981c3ef8b59560f26fd01a20fbfe030d81e) Thanks [@RealKai42](https://github.com/RealKai42)! - Add configurable character limits and resumable long-line file reads without repeated output truncation. + +- [#3645](https://github.com/MoonshotAI/kimi-code/pull/3645) [`5000f98`](https://github.com/MoonshotAI/kimi-code/commit/5000f981c3ef8b59560f26fd01a20fbfe030d81e) Thanks [@RealKai42](https://github.com/RealKai42)! - Read malformed UTF-16 files with an explicit lossy-decoding warning. + +- [#3645](https://github.com/MoonshotAI/kimi-code/pull/3645) [`5000f98`](https://github.com/MoonshotAI/kimi-code/commit/5000f981c3ef8b59560f26fd01a20fbfe030d81e) Thanks [@RealKai42](https://github.com/RealKai42)! - Avoid repeated scanning for common tail reads and report file changes detected during tail rereads. + +- [#3616](https://github.com/MoonshotAI/kimi-code/pull/3616) [`260ac3f`](https://github.com/MoonshotAI/kimi-code/commit/260ac3faad1cf0ca84dca26173ddf66a09220841) Thanks [@Grapedge](https://github.com/Grapedge)! - Upgrade the default thinking effort to the recommended level for eligible users. + +- [#3552](https://github.com/MoonshotAI/kimi-code/pull/3552) [`f0434f2`](https://github.com/MoonshotAI/kimi-code/commit/f0434f2d25ead11d44f5616930157a59dfd524a5) Thanks [@sailist](https://github.com/sailist)! - Remote Control is now always on; the experimental `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL` flag has been removed. + +- [#3618](https://github.com/MoonshotAI/kimi-code/pull/3618) [`75682b0`](https://github.com/MoonshotAI/kimi-code/commit/75682b0ef130e0340a9d16421312e2766c327be1) Thanks [@sailist](https://github.com/sailist)! - Fix recent sessions missing from the session list when the sessions directory contains stray files. + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: fix skills created mid-session not appearing in the slash list until the app restarts. + +- [#3671](https://github.com/MoonshotAI/kimi-code/pull/3671) [`e6bc8b8`](https://github.com/MoonshotAI/kimi-code/commit/e6bc8b8ad90def151f17e8e1d4b03b408d9f69ea) Thanks [@liruifengv](https://github.com/liruifengv)! - web: fix trailing backticks briefly flashing at the end of code blocks while they stream. + +- [#3596](https://github.com/MoonshotAI/kimi-code/pull/3596) [`0d7833e`](https://github.com/MoonshotAI/kimi-code/commit/0d7833ee8019d39161f8462977a3a2713bbfd39e) Thanks [@tpoisonooo](https://github.com/tpoisonooo)! - The /tasks panel now shows each background agent's model under its task row. + +- [#3607](https://github.com/MoonshotAI/kimi-code/pull/3607) [`5eea890`](https://github.com/MoonshotAI/kimi-code/commit/5eea89016511d113480755907619defafaf025cf) Thanks [@huangzheng2016](https://github.com/huangzheng2016)! - Fix the streaming debug timing attributing client-side busy time to the server. + +- [#3596](https://github.com/MoonshotAI/kimi-code/pull/3596) [`0d7833e`](https://github.com/MoonshotAI/kimi-code/commit/0d7833ee8019d39161f8462977a3a2713bbfd39e) Thanks [@tpoisonooo](https://github.com/tpoisonooo)! - Tower worker and reviewer briefings now carry the full mission context, and tower agent timeouts follow the subagent timeout setting (`[subagent] timeout_ms` or `KIMI_SUBAGENT_TIMEOUT_MS`), defaulting to 2 hours. Fix the /tasks list not showing the model for tower-spawned agents. + +- [#3593](https://github.com/MoonshotAI/kimi-code/pull/3593) [`00cfbb0`](https://github.com/MoonshotAI/kimi-code/commit/00cfbb0547cee6ec2650fd3fc52e15d2072c41aa) Thanks [@7Sageer](https://github.com/7Sageer)! - Print a warning in `kimi -p` when project-level MCP servers are skipped because the folder is not trusted. + +- [#3608](https://github.com/MoonshotAI/kimi-code/pull/3608) [`fb0353a`](https://github.com/MoonshotAI/kimi-code/commit/fb0353a8ba5ceb7e8ae4e27f3260b3c8c8d80784) Thanks [@liukx0205](https://github.com/liukx0205)! - Watch the user-level skill roots (`~/.kimi-code/skills` and `~/.agents/skills`) so the workspace skill catalog refreshes automatically when skills are created, modified, or deleted while the daemon is running — no restart or manual reload needed. + +- [#3560](https://github.com/MoonshotAI/kimi-code/pull/3560) [`af81bb9`](https://github.com/MoonshotAI/kimi-code/commit/af81bb92215dca2f933579ce0119f7add452bc96) Thanks [@kimi-agent-bot](https://github.com/kimi-agent-bot)! - Open the browser on localhost instead of the wildcard bind address for `kimi web --host 0.0.0.0`. + +- [#3605](https://github.com/MoonshotAI/kimi-code/pull/3605) [`f1e9152`](https://github.com/MoonshotAI/kimi-code/commit/f1e915277655c68213cf91fe5010413bfde3cb65) Thanks [@huangzheng2016](https://github.com/huangzheng2016)! - Fix slow response streaming and rendering after resuming sessions with many scheduled cron turns. + ## 0.41.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 3ca622cf243..a2d86dc85dd 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.41.0", + "version": "0.42.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 4ee0f26f4cb..e6022a8f119 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -16,7 +16,7 @@ import { registerWebCommand } from './sub/web'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = (options: MigrateCommandOptions) => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; -export type UpgradeCommandHandler = () => void | Promise; +export type UpgradeCommandHandler = (yes: boolean) => void | Promise; export type UpdateDownloadHandler = (version: string, manual: boolean) => void; export function createProgram( @@ -31,6 +31,7 @@ export function createProgram( .description('The Starting Point for Next-Gen Agents') .version(version, '-V, --version') .allowUnknownOption(false) + .enablePositionalOptions() .configureHelp({ helpWidth: 100 }) .helpOption('-h, --help', 'Show help.') .usage('[options] [command]') @@ -131,8 +132,9 @@ export function createProgram( .command('upgrade') .alias('update') .description('Upgrade Kimi Code to the latest version.') - .action(async () => { - await onUpgrade(); + .option('-y, --yes', 'Skip the confirmation prompt and install the update directly.', false) + .action(async (options: { yes?: boolean }) => { + await onUpgrade(options.yes === true); }); program diff --git a/apps/kimi-code/src/cli/sub/upgrade.ts b/apps/kimi-code/src/cli/sub/upgrade.ts index 861d6ddf84e..0cac2f1d9eb 100644 --- a/apps/kimi-code/src/cli/sub/upgrade.ts +++ b/apps/kimi-code/src/cli/sub/upgrade.ts @@ -46,6 +46,7 @@ export interface UpgradeDeps { readonly stdout: WritableLike; readonly stderr: WritableLike; readonly isInteractive: boolean; + readonly yes: boolean; readonly track: UpgradeTrack; readonly logger: UpgradeLogger; } @@ -88,7 +89,7 @@ export async function handleUpgrade( const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); const installCommand = installCommandFor(source, target.version, deps.platform); - if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { + if (!canAutoInstall(source, deps.platform) || (!deps.yes && !deps.isInteractive)) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { current_version: currentVersion, target_version: target.version, @@ -103,34 +104,36 @@ export async function handleUpgrade( return 0; } - trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { - current_version: currentVersion, - target_version: target.version, - source, - }); - logUpgradeInfo(deps.logger, 'manual upgrade prompted', { - currentVersion, - targetVersion: target.version, - source, - }); - const choice = await deps.promptForInstallChoice({ - currentVersion, - target, - installCommand, - installSource: source, - }); - if (choice === 'skip') { - trackUpgradeEvent(deps.track, 'upgrade_command_skipped', { + if (!deps.yes) { + trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { current_version: currentVersion, target_version: target.version, source, }); - logUpgradeInfo(deps.logger, 'manual upgrade skipped', { + logUpgradeInfo(deps.logger, 'manual upgrade prompted', { currentVersion, targetVersion: target.version, source, }); - return 0; + const choice = await deps.promptForInstallChoice({ + currentVersion, + target, + installCommand, + installSource: source, + }); + if (choice === 'skip') { + trackUpgradeEvent(deps.track, 'upgrade_command_skipped', { + current_version: currentVersion, + target_version: target.version, + source, + }); + logUpgradeInfo(deps.logger, 'manual upgrade skipped', { + currentVersion, + targetVersion: target.version, + source, + }); + return 0; + } } try { @@ -186,6 +189,7 @@ function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY), + yes: overrides.yes ?? false, track: overrides.track ?? trackTelemetry, logger: overrides.logger ?? log, }; diff --git a/apps/kimi-code/src/cli/sub/web/remote-control.ts b/apps/kimi-code/src/cli/sub/web/remote-control.ts index 4aab921c9c6..d719767768b 100644 --- a/apps/kimi-code/src/cli/sub/web/remote-control.ts +++ b/apps/kimi-code/src/cli/sub/web/remote-control.ts @@ -4,6 +4,7 @@ import { getVersion } from '../../version'; import { darkColors } from '../../../tui/theme/colors'; import { supportsHyperlinks, toTerminalHyperlink } from '../../../utils/terminal-hyperlink'; import type { RemoteControlStatus } from '@moonshot-ai/remote-control'; +import { buildOpenableUrl, splitTokenFragment } from './access-urls'; export { acquireRemoteControlLock, @@ -32,6 +33,7 @@ export type { export interface RemoteControlOutputOptions { readonly url: string; readonly localOrigin: string; + readonly localServerToken: string; readonly deviceName: string; readonly qrCode: string; readonly pngPath: string; @@ -41,12 +43,16 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions): const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); const status = (text: string): string => chalk.hex(darkColors.success)(text); const link = (url: string): string => supportsHyperlinks() ? toTerminalHyperlink(accent(url), url) : accent(url); const docs = toTerminalHyperlink('docs', 'https://kimi.com/code/docs/remote-control'); const feedback = toTerminalHyperlink('feedback', 'https://kimi.com/code/feedback'); + const [localBase, localFrag] = splitTokenFragment( + buildOpenableUrl(options.localOrigin, options.localServerToken), + ); return [ '', ` ${title('Kimi Remote Control ready')} ${muted(getVersion())}`, @@ -62,7 +68,7 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions): '', options.qrCode.trimEnd().replaceAll(/^/gm, ' '), ` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`, - ` ${label('Local UI: ')}${muted(options.localOrigin)} ${muted('(LAN: --host)')}`, + ` ${label('Local UI: ')}${accent(localBase)}${dim(localFrag)} ${muted('(LAN: --host)')}`, '', ` ${docs} ${muted('·')} ${feedback}`, ` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`, diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 878ab3cfc60..b10a666d9cd 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -233,6 +233,7 @@ export async function handleWebCommand( formatRemoteControlOutput({ url: remoteControl.url, localOrigin: origin, + localServerToken: token, deviceName: remoteControl.deviceName, qrCode: qrCode.terminal, pngPath: qrCode.pngPath, diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index e3e430ca35c..97f35d13046 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -590,6 +590,10 @@ async function runNativeTurn( // final message. writer.flushAssistant(); if (result.type === 'completed') { + const skipTurnId = turn.id; + if (skipTurnId === undefined) { + throw new Error('Prompt turn ended before it started'); + } const configService = app.accessor.get(IConfigService); const taskConfig = resolveAgentTaskConfig(configService); const goalService = agent.accessor.get(IAgentGoalService); @@ -602,7 +606,7 @@ async function runNativeTurn( countPending: () => countPendingBackgroundTasks(session), drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS), turnEndings, - skipTurnId: turn.id, + skipTurnId, warn: (message) => stderr.write(`Warning: ${message}\n`), now: () => Date.now(), goalActive: () => goalService.getGoal().goal?.status === 'active', @@ -1012,7 +1016,7 @@ async function quiesceSessionAgents( for (;;) { await Promise.allSettled(promptServices.map((service) => service.drain())); for (const loop of loops) { - for (const turnId of loop.status().pendingTurnIds) loop.cancel(turnId); + for (const queueId of loop.status().pendingPromptIds) loop.cancelQueued(queueId); loop.cancel(); } await Promise.allSettled(loops.map((loop) => loop.settled())); diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 9f78c1322ec..f95d57c2a28 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -114,7 +114,7 @@ async function handleMigrateCommand( await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); } -export async function handleUpgradeCommand(version: string): Promise { +export async function handleUpgradeCommand(version: string, yes: boolean): Promise { const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { track, @@ -137,7 +137,7 @@ export async function handleUpgradeCommand(version: string): Promise { version, uiMode: CLI_UI_MODE, }); - exitCode = await handleUpgrade(version, { track, logger: log }); + exitCode = await handleUpgrade(version, { track, logger: log, yes }); } finally { await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); await harness.close().catch(() => {}); @@ -275,8 +275,8 @@ function bootstrap(): void { process.exit(1); }); }, - () => { - void handleUpgradeCommand(version).catch(async (error: unknown) => { + (yes) => { + void handleUpgradeCommand(version, yes).catch(async (error: unknown) => { await logStartupFailure('upgrade', error); process.stderr.write(formatStartupError(error, { operation: 'upgrade' })); process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts index 80ac0a62274..95c310d71ec 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -80,6 +80,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis formatRemoteControlOutput({ url, localOrigin: origin, + localServerToken: token, deviceName: remoteControl.deviceName, qrCode: qrCode.terminal, pngPath: qrCode.pngPath, diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index 0fc77224f40..cddb55377ef 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -13,6 +13,7 @@ import { import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; export interface SessionRow { @@ -81,7 +82,7 @@ function sessionSearchText(session: SessionRow): string { export class SessionPickerComponent extends Container implements Focusable { private sessions: SessionRow[]; private currentSessionId: string; - private onSelect: (session: SessionRow) => void; + private onSelect: (session: SessionRow) => void | Promise; private onCancel: () => void; private onToggleScope?: (selectedSessionId: string) => void; private maxVisibleSessions: number; @@ -92,6 +93,8 @@ export class SessionPickerComponent extends Container implements Focusable { private hasMore: boolean; private loadingMore: boolean; private list: SearchableList; + private deleteState?: { session: SessionRow; phase: 'confirm' | 'deleting' }; + private selectInFlight = false; focused = false; @@ -102,7 +105,7 @@ export class SessionPickerComponent extends Container implements Focusable { scope?: 'cwd' | 'all'; initialSelectedSessionId?: string; pageSize?: number; - onSelect: (session: SessionRow) => void; + onSelect: (session: SessionRow) => void | Promise; onCancel: () => void; onCtrlC?: () => void; onCtrlD?: () => void; @@ -116,6 +119,8 @@ export class SessionPickerComponent extends Container implements Focusable { onLoadMore?: () => void; /** Fired when a search query becomes active while pages remain unfetched. */ onSearchDrain?: () => void; + /** Fired after the user confirms deletion with `y`; the picker clears its delete state once the request settles. */ + onDeleteRequest?: (session: SessionRow) => Promise; }) { super(); this.sessions = opts.sessions; @@ -143,12 +148,14 @@ export class SessionPickerComponent extends Container implements Focusable { this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize); this.onCtrlC = opts.onCtrlC; this.onCtrlD = opts.onCtrlD; + this.onDeleteRequest = opts.onDeleteRequest; } private readonly onCtrlC?: () => void; private readonly onCtrlD?: () => void; private readonly onLoadMore?: () => void; private readonly onSearchDrain?: () => void; + private readonly onDeleteRequest?: (session: SessionRow) => Promise; /** Appends a freshly fetched page, keeping the cursor and active query. */ appendSessions(rows: SessionRow[]): void { @@ -210,6 +217,13 @@ export class SessionPickerComponent extends Container implements Focusable { } handleInput(data: string): void { + if (this.deleteState !== undefined) { + this.handleDeleteInput(data); + return; + } + // A selection runs resume/switch asynchronously; input during that window + // (e.g. Ctrl+X delete) would race the session swap. + if (this.selectInFlight) return; if (matchesKey(data, Key.ctrl('c'))) { this.onCtrlC?.(); return; @@ -222,6 +236,14 @@ export class SessionPickerComponent extends Container implements Focusable { this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId); return; } + if (matchesKey(data, Key.ctrl('x'))) { + const selected = this.list.selected(); + if (selected !== undefined && this.onDeleteRequest !== undefined) { + this.deleteState = { session: selected, phase: 'confirm' }; + this.invalidate(); + } + return; + } if (matchesKey(data, Key.escape)) { if (this.list.clearQuery()) { this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize); @@ -232,7 +254,16 @@ export class SessionPickerComponent extends Container implements Focusable { } if (matchesKey(data, Key.enter)) { const session = this.list.selected(); - if (session) this.onSelect(session); + if (session) { + const selection = this.onSelect(session); + if (selection !== undefined) { + this.selectInFlight = true; + const clear = (): void => { + this.selectInFlight = false; + }; + void selection.then(clear, clear); + } + } return; } @@ -242,6 +273,54 @@ export class SessionPickerComponent extends Container implements Focusable { } } + private handleDeleteInput(data: string): void { + const state = this.deleteState; + if (state === undefined || state.phase === 'deleting') return; + const k = printableChar(data); + if (matchesKey(data, Key.escape) || k === 'n' || k === 'N') { + this.deleteState = undefined; + this.invalidate(); + return; + } + if (k === 'y' || k === 'Y') { + this.deleteState = { session: state.session, phase: 'deleting' }; + this.invalidate(); + const sessionId = state.session.id; + const clear = (): void => { + if (this.deleteState?.session.id !== sessionId) return; + this.deleteState = undefined; + this.invalidate(); + }; + // then(clear, clear): rejections settle too — the host has already surfaced the failure. + void this.onDeleteRequest?.(state.session).then(clear, clear); + } + } + + private renderDeleteStateLine(width: number): string { + const state = this.deleteState; + if (state === undefined) return ''; + const rawTitle = (state.session.title ?? state.session.id).trim() || state.session.id; + const label = singleLine( + formatSessionLabel({ title: rawTitle, metadata: state.session.metadata }), + ); + const prefix = state.phase === 'confirm' ? 'Delete session "' : 'Deleting session "'; + const suffix = state.phase === 'confirm' ? '"? [y/N]' : '"…'; + const labelBudget = Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix)); + const shown = truncateToWidth(label, labelBudget, ELLIPSIS); + // The suffix carries the confirm/cancel keys: it survives by truncating + // the head (prefix + label) instead of the composed line. + const head = truncateToWidth( + prefix + shown, + Math.max(0, width - visibleWidth(suffix)), + ELLIPSIS, + ); + const styled = + state.phase === 'confirm' + ? currentTheme.boldFg('warning', head + suffix) + : currentTheme.fg('textMuted', head + suffix); + return truncateToWidth(styled, width, ELLIPSIS); + } + override render(width: number): string[] { return this.renderLines(width).map((line) => truncateToWidth(line, width, ELLIPSIS)); } @@ -294,6 +373,7 @@ export class SessionPickerComponent extends Container implements Focusable { ...(view.query.length > 0 ? ['Backspace clear'] : []), '↑↓ navigate', scopeHint, + ...(this.onDeleteRequest !== undefined ? ['Ctrl+X delete'] : []), 'Enter select', 'Esc cancel', ].filter((item): item is string => item !== undefined); @@ -361,6 +441,11 @@ export class SessionPickerComponent extends Container implements Focusable { lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } + if (this.deleteState !== undefined) { + lines.push(''); + lines.push(this.renderDeleteStateLine(width)); + } + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index f75615b08e1..f1b951ec16c 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -6,9 +6,9 @@ export const MESSAGE_INDENT = ' '; // transcript messages. The fullscreen renderer strips them at paint and uses // the A marker for previous/next-prompt navigation (Ctrl-Shift-Up/Down); in // regular mode they pass through to native scrollback invisibly. -export const OSC133_ZONE_START = '\x1B]133;A\x07'; -export const OSC133_ZONE_END = '\x1B]133;B\x07'; -export const OSC133_ZONE_FINAL = '\x1B]133;C\x07'; +export const OSC133_ZONE_START = '\u001B]133;A\u0007'; +export const OSC133_ZONE_END = '\u001B]133;B\u0007'; +export const OSC133_ZONE_FINAL = '\u001B]133;C\u0007'; // Outer left/right padding applied to the transcript, panels, and the // statusline so the chrome's left edge lines up with the input box's @@ -30,7 +30,7 @@ export const TRUNCATION_ELLIPSIS = '…'; // ANSI escape sequences (CSI, OSC) — tool output can carry them — that a // width-aware cut must treat as zero-width atomic units: never counted toward // the budget, never split in half. -export const ANSI_ESCAPE_PATTERN = /\x1B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))/g; +export const ANSI_ESCAPE_PATTERN = /\u001B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\))/g; // Code units a single terminal cell may hold before a tail-preserving cut's // window can no longer see it: a ZWJ family emoji is about eleven per two // cells, and combining sequences run longer. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b5db57154b0..e9903366875 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -3936,23 +3936,7 @@ export class KimiTUI { }): Promise { this.sessionPickerOptions = options; await this.fetchSessions('cwd'); - this.mountSessionPicker({ - applyStartupModes: options.applyStartupModes, - onCancel: () => { - this.hideSessionPicker(); - if (options.closeOnCancel) void this.stop(); - }, - onCtrlC: options.forwardEditorExit - ? () => { - this.state.editor.onCtrlC?.(); - } - : undefined, - onCtrlD: options.forwardEditorExit - ? () => { - this.state.editor.onCtrlD?.(); - } - : undefined, - }); + this.remountSessionPicker(); } private async toggleSessionPickerScope(selectedSessionId: string): Promise { @@ -3961,8 +3945,12 @@ export class KimiTUI { await this.fetchSessions(nextScope); if (requestToken !== this.sessionPickerScopeRequestToken) return; if (this.state.activeDialog !== 'session-picker') return; + this.remountSessionPicker(selectedSessionId); + } + + private remountSessionPicker(initialSelectedSessionId?: string): void { this.mountSessionPicker({ - initialSelectedSessionId: selectedSessionId, + initialSelectedSessionId, applyStartupModes: this.sessionPickerOptions.applyStartupModes, onCancel: () => { this.hideSessionPicker(); @@ -3989,6 +3977,68 @@ export class KimiTUI { this.restoreEditor(); } + private async deleteSessionFromPicker(session: SessionRow): Promise { + // Invalidate any pending scope-toggle remount: it would replace the picker + // that is about to lock itself for the delete. + this.sessionPickerScopeRequestToken += 1; + try { + await this.waitForLazyCreation(); + if (session.id === this.state.appState.sessionId && this.session !== undefined) { + await this.deleteCurrentSessionFromPicker(session); + return; + } + await this.harness.deleteSession(session.id); + // fetchSessions swallows refetch errors, so drop the row locally first — + // a failed refetch must not resurrect it in the remounted list. + this.state.sessions = this.state.sessions.filter((row) => row.id !== session.id); + const requestToken = ++this.sessionPickerScopeRequestToken; + await this.fetchSessions(this.state.sessionsScope); + if (requestToken !== this.sessionPickerScopeRequestToken) return; + if (this.state.activeDialog !== 'session-picker') return; + this.remountSessionPicker(); + this.showStatus('Session deleted.'); + } catch (error) { + this.showError(`Failed to delete session ${session.id}: ${formatErrorMessage(error)}`); + } + } + + private async deleteCurrentSessionFromPicker(session: SessionRow): Promise { + // The picker stays mounted (locking input) until the replacement session + // is ready — restoring the editor mid-flight would let a prompt race the swap. + try { + // Tear down before deleting so no events from the dying session reach the UI. + await this.closeSession('deleting session'); + await this.harness.deleteSession(session.id); + } catch (error) { + // The engine aborts a failed delete and keeps the session: reattach, + // falling back to a fresh session if it is gone. showError runs after + // the switch because switchToSession clears the transcript. + const message = `Failed to delete session ${session.id}: ${formatErrorMessage(error)}`; + try { + const resumed = await this.harness.resumeSession({ + id: session.id, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + await this.switchToSession(resumed, `Resumed session (${resumed.id}).`); + } catch { + // Reattach failed and the session is already unloaded: detach before + // the fallback create so a failed create leaves no ghost UI behind. + this.setAppState({ sessionId: '' }); + this.clearTranscriptAndRedraw(); + await this.createNewSession(); + } + this.showError(message); + this.hideSessionPicker(); + return; + } + // The session is gone whether or not replacement creation succeeds: detach + // first so a failed create leaves no ghost (stale id + transcript) behind. + this.setAppState({ sessionId: '' }); + this.clearTranscriptAndRedraw(); + await this.createNewSession(); + this.hideSessionPicker(); + } + openUndoSelector(): void { void slashCommands.handleUndoCommand(this, ''); } @@ -4019,19 +4069,19 @@ export class KimiTUI { onSearchDrain: () => { void this.drainSessionsForSearch(); }, - onSelect: (session: SessionRow) => { - void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + onSelect: (session: SessionRow) => + this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( (error) => { this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); }, - ); - }, + ), onCancel: options.onCancel, onCtrlC: options.onCtrlC, onCtrlD: options.onCtrlD, onToggleScope: (selectedSessionId: string) => { void this.toggleSessionPickerScope(selectedSessionId); }, + onDeleteRequest: (session: SessionRow) => this.deleteSessionFromPicker(session), }); this.sessionPickerComponent = picker; this.mountEditorReplacement(picker); @@ -4041,6 +4091,9 @@ export class KimiTUI { session: SessionRow, applyStartupModes: boolean, ): Promise { + // Invalidate any pending scope-toggle remount: it would replace the picker + // and drop the selection lock. + this.sessionPickerScopeRequestToken += 1; if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) { await this.showResumeOtherWorkDirHint(session); if (applyStartupModes) await this.stop(0); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index d25db275bae..e6a2fb1b52a 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -113,7 +113,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { .getText() .then((text) => { if (!text || ui.getFocusedComponent() !== target) return; - target.handleInput?.(`\x1B[200~${text}\x1B[201~`); + target.handleInput?.(`\u001B[200~${text}\u001B[201~`); ui.requestRender(); }) .catch(() => {}); diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index d115c6e7687..edaf222842a 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -205,12 +205,12 @@ async function runHandleMainCommand(opts: CLIOptions): Promise { } } -async function runHandleUpgradeCommand(): Promise { +async function runHandleUpgradeCommand(yes = false): Promise { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); }); try { - await handleUpgradeCommand('0.0.1-alpha.2'); + await handleUpgradeCommand('0.0.1-alpha.2', yes); throw new Error('expected process.exit'); } catch (error) { if (error instanceof ExitCalled) { @@ -464,6 +464,7 @@ describe('main entry command handling', () => { expect(mocks.handleUpgrade).toHaveBeenCalledWith('0.0.1-alpha.2', { track: mocks.track, logger: mocks.log, + yes: false, }); expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 }); expect(mocks.harness.close).toHaveBeenCalledTimes(1); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 23cd8ccc72a..aa1cfb2cac4 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -524,7 +524,7 @@ describe('CLI options parsing', () => { describe('sub-commands', () => { it('routes upgrade without calling the main action', () => { - let upgradeCalls = 0; + const upgradeYes: boolean[] = []; const program = createProgram( '0.0.0', () => { @@ -532,8 +532,8 @@ describe('CLI options parsing', () => { }, () => {}, () => {}, - () => { - upgradeCalls += 1; + (yes) => { + upgradeYes.push(yes); }, ); program.exitOverride(); @@ -544,11 +544,11 @@ describe('CLI options parsing', () => { program.parse(['node', 'kimi', 'upgrade']); - expect(upgradeCalls).toBe(1); + expect(upgradeYes).toEqual([false]); }); it('routes update alias to the upgrade handler', () => { - let upgradeCalls = 0; + const upgradeYes: boolean[] = []; const program = createProgram( '0.0.0', () => { @@ -556,8 +556,8 @@ describe('CLI options parsing', () => { }, () => {}, () => {}, - () => { - upgradeCalls += 1; + (yes) => { + upgradeYes.push(yes); }, ); program.exitOverride(); @@ -566,9 +566,9 @@ describe('CLI options parsing', () => { writeErr: () => {}, }); - program.parse(['node', 'kimi', 'update']); + program.parse(['node', 'kimi', 'update', '-y']); - expect(upgradeCalls).toBe(1); + expect(upgradeYes).toEqual([true]); }); it('registers the visible sub-commands', () => { diff --git a/apps/kimi-code/test/cli/upgrade.test.ts b/apps/kimi-code/test/cli/upgrade.test.ts index 31b53b1d983..db270f94a8d 100644 --- a/apps/kimi-code/test/cli/upgrade.test.ts +++ b/apps/kimi-code/test/cli/upgrade.test.ts @@ -157,7 +157,7 @@ describe('handleUpgrade', () => { expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); }); - it('prints the manual update command without prompting when not interactive', async () => { + it('prints the manual update command without prompting when not interactive, and installs directly with yes', async () => { const { stdout, writable } = captureOutput(); const deps = createDeps({ latest: '0.5.0', source: 'npm-global', isInteractive: false }); @@ -170,6 +170,20 @@ describe('handleUpgrade', () => { source: 'npm-global', })); expect(stdout.join('')).toContain('To update manually, run: npm install -g @moonshot-ai/kimi-code@0.5.0'); + + const yesRun = captureOutput(); + const yesDeps = createDeps({ latest: '0.5.0', source: 'npm-global', isInteractive: false }); + + await expect(handleUpgrade('0.4.0', { ...yesDeps, ...yesRun.writable, yes: true })).resolves.toBe(0); + + expect(yesDeps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(yesDeps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(yesDeps.track).not.toHaveBeenCalledWith('upgrade_command_prompted', expect.anything()); + expect(yesDeps.track).toHaveBeenCalledWith('upgrade_command_install_selected', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(yesRun.stdout.join('')).toContain('Updated @moonshot-ai/kimi-code to 0.5.0'); }); it('returns a failing exit code when the foreground install fails', async () => { diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 867a5e51483..6909ad6b7b0 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -211,7 +211,7 @@ function makeFakeHarness() { [ IAgentLoopService, { - status: vi.fn(() => ({ state: 'idle', pendingTurnIds: [] })), + status: vi.fn(() => ({ state: 'idle', pendingPromptIds: [] })), cancel: vi.fn(() => false), settled: vi.fn(async () => {}), tryAcquireQuiescence: vi.fn(() => ({ dispose: vi.fn() })), @@ -757,7 +757,7 @@ describe('runV2Print', () => { settled: ReturnType; tryAcquireQuiescence: ReturnType; }; - loop.status.mockReturnValue({ state: 'running', pendingTurnIds: [] }); + loop.status.mockReturnValue({ state: 'running', pendingPromptIds: [] }); loop.cancel.mockImplementation(() => { if (!order.includes('cancel')) order.push('cancel'); return true; diff --git a/apps/kimi-code/test/cli/web/remote-control.test.ts b/apps/kimi-code/test/cli/web/remote-control.test.ts index 1d0804631e2..cc0f8ba9c78 100644 --- a/apps/kimi-code/test/cli/web/remote-control.test.ts +++ b/apps/kimi-code/test/cli/web/remote-control.test.ts @@ -13,6 +13,7 @@ describe('Remote Control output', () => { const outputOptions = { url: 'https://example.test/devices/example-device/?rc=1&from=kimi_code_cli', localOrigin: 'http://127.0.0.1:1234', + localServerToken: 'example-token', deviceName: 'example-device', qrCode: 'QR\n', pngPath: '/tmp/example-qr.png', @@ -32,6 +33,8 @@ describe('Remote Control output', () => { .replaceAll(/\u001B\[[0-9;]*m/g, ''); expect(plain).toContain(`open ${url}`); expect(plain).not.toContain('exampl…'); + expect(plain).toContain('http://127.0.0.1:1234/#token=example-token'); + expect(output).toContain('#token=example-token'); expect(output).toContain('Connected to example.test'); expect(output).toContain('This device:'); expect(output).not.toContain('Manage devices'); @@ -48,6 +51,7 @@ describe('Remote Control output', () => { vi.stubEnv('FORCE_HYPERLINK', '0'); const output = formatRemoteControlOutput(outputOptions); expect(output).toContain(`open ${outputOptions.url}`); + expect(output).toContain('#token=example-token'); expect(output).not.toContain('exampl…vice'); expect(output).not.toContain('Manage devices'); }); diff --git a/apps/kimi-code/test/tui/commands/compact-threshold.test.ts b/apps/kimi-code/test/tui/commands/compact-threshold.test.ts index a65e258af01..983367c2683 100644 --- a/apps/kimi-code/test/tui/commands/compact-threshold.test.ts +++ b/apps/kimi-code/test/tui/commands/compact-threshold.test.ts @@ -21,7 +21,7 @@ function makeHost(options: { hasSession?: boolean; status?: SessionStatusLike } contextTokens: 0, maxContextTokens: 1000, contextUsage: 0, - ...(options.status ?? {}), + ...options.status, })), }; const hasSession = options.hasSession ?? true; diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index 31d0b7307d7..d31e10ca416 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -243,8 +243,11 @@ describe('handleRemoteControlCommand', () => { const png = readFileSync(pngPath); expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); expect(png).toEqual(await QRCode.toBuffer(sessionUrl)); - expect(written).not.toContain('local-server-token'); - expect(written).not.toContain('#token='); + expect(written).toContain( + 'Local UI: http://127.0.0.1:58627/#token=local-server-token', + ); + expect(written).not.toContain(`${entryUrl}#token=`); + expect(written).not.toContain(`${sessionUrl}#token=`); expect(close).toHaveBeenCalledOnce(); } finally { writeSpy.mockRestore(); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 222adfa6a59..a4af77adaaf 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -869,4 +869,299 @@ describe('SessionPickerComponent', () => { expect(renderPlain(component)).toContain('· searching all…'); }); + + describe('session deletion', () => { + const CTRL_X = '\u0018'; + + function deferred(): { + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; + } { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + async function flushMicrotasks(): Promise { + await new Promise((r) => { + setTimeout(r, 0); + }); + } + + const alpha = { id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 2 }; + const beta = { id: 'ses_beta', title: 'Beta session', work_dir: '/tmp/p', updated_at: 1 }; + + it('arms an inline delete confirmation on Ctrl+X for the selected row', () => { + const onDeleteRequest = vi.fn(async () => {}); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + expect(onDeleteRequest).not.toHaveBeenCalled(); + }); + + it('does nothing on Ctrl+X without a delete handler or a selected row', () => { + const noHandler = new SessionPickerComponent({ + sessions: [alpha], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + noHandler.handleInput(CTRL_X); + expect(renderPlain(noHandler)).not.toContain('Delete session'); + + const noRows = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + noRows.handleInput(CTRL_X); + expect(renderPlain(noRows)).not.toContain('Delete session'); + }); + + it('confirms on y, shows a deleting state, and clears it after success', async () => { + const { promise, resolve } = deferred(); + const onDeleteRequest = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('y'); + + expect(onDeleteRequest).toHaveBeenCalledOnce(); + expect(onDeleteRequest).toHaveBeenCalledWith(alpha); + expect(renderPlain(component)).toContain('Deleting session "Alpha session"…'); + + resolve(); + await flushMicrotasks(); + + const output = renderPlain(component); + expect(output).not.toContain('Deleting session'); + expect(output).not.toContain('Delete session'); + }); + + it('cancels on n and on Esc without calling onDeleteRequest or onCancel', () => { + const onDeleteRequest = vi.fn(async () => {}); + const onCancel = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel, + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('n'); + expect(renderPlain(component)).not.toContain('Delete session'); + + component.handleInput(CTRL_X); + component.handleInput(ESC); + expect(renderPlain(component)).not.toContain('Delete session'); + + expect(onDeleteRequest).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('ignores all other keys while the confirmation is armed', () => { + const onDeleteRequest = vi.fn(async () => {}); + const onSelect = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('\r'); + component.handleInput('\u001B[B'); + component.handleInput('x'); + component.handleInput(CTRL_X); + + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + expect(onDeleteRequest).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores keys while a delete is in flight', async () => { + const { promise, resolve } = deferred(); + const onDeleteRequest = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('y'); + component.handleInput('y'); + component.handleInput(CTRL_X); + component.handleInput('\r'); + component.handleInput(ESC); + + expect(onDeleteRequest).toHaveBeenCalledOnce(); + + resolve(); + await flushMicrotasks(); + }); + + it('returns to the list when the delete fails', async () => { + const { promise, reject } = deferred(); + const onDeleteRequest = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('y'); + reject(new Error('boom')); + await flushMicrotasks(); + + const output = renderPlain(component); + expect(output).not.toContain('Deleting session'); + expect(output).not.toContain('Delete session'); + expect(onDeleteRequest).toHaveBeenCalledOnce(); + }); + + it('adds Ctrl+X delete to the hint when deletion is available', () => { + const component = new SessionPickerComponent({ + sessions: [alpha], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + + expect(renderPlain(component)).toContain('Ctrl+X delete'); + }); + + it('ignores input while a selection is in flight', async () => { + const { promise, resolve } = deferred(); + const onSelect = vi.fn(() => promise); + const onDeleteRequest = vi.fn(async () => {}); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledOnce(); + + component.handleInput(CTRL_X); + expect(renderPlain(component)).not.toContain('Delete session'); + component.handleInput('y'); + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onDeleteRequest).not.toHaveBeenCalled(); + + resolve(); + await flushMicrotasks(); + + component.handleInput(CTRL_X); + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + }); + + it('unlocks input when the selection fails', async () => { + const { promise, reject } = deferred(); + const onSelect = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledOnce(); + + reject(new Error('boom')); + await flushMicrotasks(); + + component.handleInput(CTRL_X); + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + }); + + it('keeps every line within the terminal width with a delete confirmation armed', () => { + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + component.handleInput(CTRL_X); + + for (const width of [10, 20, 24, 40]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('keeps the [y/N] confirmation keys visible when the title is truncated', () => { + const longTitled = { + id: 'ses_long', + title: 'A very long session title that cannot fit a narrow terminal', + work_dir: '/tmp/p', + updated_at: 2, + }; + const component = new SessionPickerComponent({ + sessions: [longTitled], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + + component.handleInput(CTRL_X); + + for (const width of [40, 24, 20, 12, 8]) { + expect(renderPlain(component, width)).toContain('? [y/N]'); + } + }); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index b3e25b3c491..567220d54ae 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1184,6 +1184,466 @@ describe('KimiTUI startup', () => { expect(output).not.toContain('Search: cwd'); }); + it('deletes a session from the picker and refreshes the list', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const sesB = { + id: 'ses-b', + title: 'Session B', + workDir: '/tmp/proj-a', + updatedAt: Date.now() - 1000, + }; + let deleted = false; + const listSessions = vi.fn(async () => (deleted ? [sesB] : [sesA, sesB])); + const deleteSession = vi.fn(async () => { + deleted = true; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, deleteSession }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + picker.handleInput('\u0018'); + expect(picker.render(160).join('\n')).toContain('Delete session "Session A"? [y/N]'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-a'); + }); + await vi.waitFor(() => { + const remounted = driver.state.editorContainer.children[0] as { + render(width: number): string[]; + }; + expect(remounted.render(160).join('\n')).not.toContain('Session A'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + }); + + it('deleting the current session closes it, deletes it, and starts a new session', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + let resolveDelete!: () => void; + const deleteSession = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + expect(driver.state.appState.sessionId).toBe('ses-current'); + // Contentless current sessions are filtered out of picker rows; fake content so the row exists. + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + // The picker (and its input lock) stays mounted until the replacement + // session is ready; the editor must not accept input mid-flight. + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-current'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + resolveDelete(); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(2); + }); + expect(session.close).toHaveBeenCalled(); + expect(driver.state.activeDialog).toBeNull(); + }); + + it('reattaches to the current session when deleting it fails', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => { + throw new Error('boom'); + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-current', + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + }); + await vi.waitFor(() => { + expect(driver.state.appState.sessionId).toBe('ses-current'); + }); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-current'); + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + it('reattaches when closing the current session fails during deletion', async () => { + const session = makeSession({ + id: 'ses-current', + close: vi.fn(async () => { + throw new Error('close boom'); + }), + }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-current', + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + }); + expect(deleteSession).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(driver.state.appState.sessionId).toBe('ses-current'); + }); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-current'); + }); + + it('drops the deleted row locally when the post-delete list refresh fails', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const sesB = { + id: 'ses-b', + title: 'Session B', + workDir: '/tmp/proj-a', + updatedAt: Date.now() - 1000, + }; + let refreshCalls = 0; + const listSessions = vi.fn(async () => { + refreshCalls += 1; + if (refreshCalls > 1) throw new Error('refresh boom'); + return [sesA, sesB]; + }); + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, deleteSession }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-a'); + }); + await vi.waitFor(() => { + const remounted = driver.state.editorContainer.children[0] as { + render(width: number): string[]; + }; + const output = remounted.render(160).join('\n'); + expect(output).not.toContain('Session A'); + expect(output).toContain('Session B'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + }); + + it('keeps the picker open and surfaces an error when deletion fails', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const deleteSession = vi.fn(async () => { + throw new Error('boom'); + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + listSessions: vi.fn(async () => [sesA]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-a'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + }); + + it('does not arm deletion while a picker selection is in flight', async () => { + const picked = makeSession({ id: 'ses-2' }); + let resolveResume!: (session: unknown) => void; + const resumeSession = vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ); + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + deleteSession, + listSessions: vi.fn(async () => [ + { id: 'ses-2', title: 'Other session', workDir: '/tmp/proj-a', updatedAt: Date.now() }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + picker.handleInput('\r'); + await vi.waitFor(() => { + expect(resumeSession).toHaveBeenCalled(); + }); + + picker.handleInput('\u0018'); + expect(picker.render(160).join('\n')).not.toContain('Delete session'); + expect(deleteSession).not.toHaveBeenCalled(); + + resolveResume(picked); + await vi.waitFor(() => { + expect(driver.state.activeDialog).toBeNull(); + }); + }); + + it('does not remount the picker while a deletion is in flight and a scope toggle is pending', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const sesB = { + id: 'ses-b', + title: 'Session B', + workDir: '/tmp/proj-a', + updatedAt: Date.now() - 1000, + }; + let resolveAllSessions: ((value: unknown[]) => void) | undefined; + let resolveDelete: (() => void) | undefined; + let allFetchPending = true; + const listSessions = vi.fn((input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return Promise.resolve([sesA, sesB]); + if (allFetchPending) { + allFetchPending = false; + return new Promise((resolve) => { + resolveAllSessions = resolve; + }); + } + return Promise.resolve([sesA, sesB]); + }); + const deleteSession = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, deleteSession }); + const driver = makeDriver(harness, makeStartupInput()); + const mountSessionPicker = vi.spyOn( + driver as unknown as { mountSessionPicker(options: unknown): void }, + 'mountSessionPicker', + ); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + picker.handleInput('\u0018'); + picker.handleInput('y'); + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-a'); + }); + + resolveAllSessions?.([sesA, sesB]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + expect(driver.state.editorContainer.children[0]).toBe(picker); + + resolveDelete?.(); + await vi.waitFor(() => { + expect(mountSessionPicker).toHaveBeenCalledTimes(2); + }); + }); + + it('does not remount the picker while a selection is in flight and a scope toggle is pending', async () => { + const picked = makeSession({ id: 'ses-2' }); + const ses2 = { id: 'ses-2', title: 'Other session', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + let resolveAllSessions: ((value: unknown[]) => void) | undefined; + let resolveResume: ((value: unknown) => void) | undefined; + const listSessions = vi.fn((input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return Promise.resolve([ses2]); + return new Promise((resolve) => { + resolveAllSessions = resolve; + }); + }); + const resumeSession = vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, resumeSession }); + const driver = makeDriver(harness, makeStartupInput()); + const mountSessionPicker = vi.spyOn( + driver as unknown as { mountSessionPicker(options: unknown): void }, + 'mountSessionPicker', + ); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + picker.handleInput('\r'); + await vi.waitFor(() => { + expect(resumeSession).toHaveBeenCalled(); + }); + + resolveAllSessions?.([ses2]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + expect(driver.state.editorContainer.children[0]).toBe(picker); + + resolveResume?.(picked); + await vi.waitFor(() => { + expect(driver.state.activeDialog).toBeNull(); + }); + }); + + it('resets the detached UI when replacement creation fails after deleting the current session', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + expect(driver.state.appState.sessionId).toBe('ses-current'); + // Contentless current sessions are filtered out of picker rows; fake content so the row exists. + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + harness.createSession.mockRejectedValueOnce(new Error('create boom')); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to start a new session'); + }); + expect(driver.state.appState.sessionId).toBe(''); + expect(driver.state.activeDialog).toBeNull(); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).not.toContain('Started a new session (ses-current)'); + }); + + it('resets the detached UI when recovery creation also fails after a failed delete', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => { + throw new Error('delete boom'); + }); + const resumeSession = vi.fn(async () => { + throw new Error('resume boom'); + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + resumeSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + expect(driver.state.appState.sessionId).toBe('ses-current'); + // Contentless current sessions are filtered out of picker rows; fake content so the row exists. + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + harness.createSession.mockRejectedValueOnce(new Error('create boom')); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-current'); + }); + expect(driver.state.appState.sessionId).toBe(''); + expect(driver.state.activeDialog).toBeNull(); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).not.toContain('Started a new session (ses-current)'); + }); + it('does not resume a session from a different cwd and shows a cd hint', async () => { const currentWorkDirSession = { id: 'ses-cwd', diff --git a/apps/kimi-inspect/AGENTS.md b/apps/kimi-inspect/AGENTS.md index 88f0a02604f..0c90018e094 100644 --- a/apps/kimi-inspect/AGENTS.md +++ b/apps/kimi-inspect/AGENTS.md @@ -37,13 +37,13 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views: - **Chat workspace** — the per-session chat (see "Chat view" below), with the session tree on the left: `src/components/Sidebar.tsx` is a single-column workspace → session tree over the v2 list's grouped projection (`GET /api/v2/sessions?view=by_workspace`, client in `src/sessions/api.ts` — v1-style `{ code, msg, data }` envelope, opaque-cursor pagination over groups; each workspace group carries its first `group.page_size` sessions plus the full matching total, and a "Show all" row falls back to the flat per-workspace listing). Preset views in `src/sessions/views.ts` map onto the endpoint's status / archived / git query conditions; the active view, collapsed workspaces, and panel width persist to localStorage; live activity badges come from the hub. - **Global message search** (`src/components/SearchView.tsx`) — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index). -- **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies. Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. +- **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies, with per-model ping and session creation actions. - **App Services** (`src/components/AppServicesView.tsx`) — the app-scope Service reflection, full width, joined by the **Workspace Services** view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`. - **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx` — Audit / Agent / State / Session tabs) across two of them: -- `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan`) plus the agent Service panels. +- `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — deriving the reviewed plan of one ExitPlanMode tool call, or every plan of the agent, from the message stream: a full `GET /sessions/{id}/history` read via `src/transcript/api.ts`'s `fetchFullHistory` + client-side `projectPlans` in `src/transcript/plan.ts`) plus the agent Service panels. - `State` tab — every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`. The **Session scope** lives in the same right dock as the `Session` tab (`src/components/SessionPane.tsx`, embedded by `RightPanel`) with two sub-tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx`, which lists and answers approvals/questions over the public REST endpoints `/api/v1/sessions/{id}/approvals|questions` via `src/interactions/api.ts`, since the interaction kernel is a process-global singleton with no debug channel — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`). @@ -62,12 +62,12 @@ The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, d ## Chat view -The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses. +The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **message protocol v3** surface and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses. -Full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library). +Persisted state comes from `GET /api/v1/sessions/{id}/history` only (client in `src/transcript/api.ts`): the initial load reads the newest page (default 500 messages, replace mode), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view (prepend mode; a short or empty page ends paging — the response deliberately carries no has-more flag), and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library). -`/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). +`/api/v3/ws` (client in `src/transcript/ws.ts`) is the live channel: server `hello` → `subscribe {id, session_id, agent_ids: [agent]}` → `ack` → recovery payload (in-flight entities + pending interactions + running tasks + todo + `session.state`) → live traffic, heartbeat at the WS protocol level. The store (`src/transcript/store.ts`) applies recovery and live messages through one idempotent path — entity messages upsert by (type, own id) with content fields authoritative, the delta family (`assistant.delta` / `thinking.delta` / `tool_call.delta`) appends by id, `tool.progress` patches the entity, `system(undo/clear)` truncates the timeline by `payload.removed_ids` (subtree included, linked interactions cascaded), and `interaction` / `task` / `todo` / `session.state` upsert their own single-source maps; an upsert older than the held entity's `timestamp` is skipped. Notifications are throttled trailing-edge so the per-token delta stream does not re-render per token. Every subscribe ack (initial and every reconnect) triggers an `after_step` catch-up from the newest terminal step; an empty catch-up whose anchor vanished (undo/clear while away) falls back to a full refresh. All of this is orchestrated by `ChatChannel` (`src/transcript/channel.ts`) — no buffering, no cursors beyond the two REST page cursors, no reset frames. ## Transcript audit panel -The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. +The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST history page (request + replace/prepend/tail mode), every WS message (entity/delta/state as applied), channel events (subscribe ack, reconnect, catch-up fallback, protocol errors), and prompt/cancel actions — with the resulting immutable `ChatState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view (the flat entity timeline plus the interaction/task/todo/session.state entities), and the raw Event payload. diff --git a/apps/kimi-inspect/README.md b/apps/kimi-inspect/README.md index 3c90d456236..50ca8db5d75 100644 --- a/apps/kimi-inspect/README.md +++ b/apps/kimi-inspect/README.md @@ -28,9 +28,8 @@ there is no fallback data source. - **Search** — cross-session full-text search over `POST /api/v1/search` (cursor-paged; exact-match maps to the API's `literal` mode; a `live`/`index` badge shows which server route served the results). -- **Model Catalog** — every provider with its models; expanding one opens the - model inspector (config layers + resolved runtime view with per-value - provenance). +- **Model Catalog** — every provider with its models and the default marker, + with per-model ping and session creation actions. - **App / Workspace Services** — the full Service reflection over the App scope, and over each Workspace scope (picked via the directory browser; workspace handlers materialize on demand). diff --git a/apps/kimi-inspect/package.json b/apps/kimi-inspect/package.json index c347485a4d7..ee37ebc9497 100644 --- a/apps/kimi-inspect/package.json +++ b/apps/kimi-inspect/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@moonshot-ai/agent-core-v2": "workspace:^", - "@moonshot-ai/transcript": "workspace:^", + "@moonshot-ai/kap-server": "workspace:^", "@tanstack/react-query": "^5.74.4", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/apps/kimi-inspect/src/audit/audit.test.ts b/apps/kimi-inspect/src/audit/audit.test.ts index 0043ec73bac..45c586c95c5 100644 --- a/apps/kimi-inspect/src/audit/audit.test.ts +++ b/apps/kimi-inspect/src/audit/audit.test.ts @@ -3,27 +3,57 @@ * and tail-preserving truncation used by the chat view's audit panel. */ -import { EMPTY_AGENT_STATE, type AgentState, type TranscriptTurn } from '@moonshot-ai/transcript'; +import type { StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol'; import { describe, expect, it } from 'vitest'; +import { EMPTY_CHAT_STATE, type ChatState } from '../transcript/store'; import { diffValue, type DiffNode } from './diff'; import { serializeState } from './serialize'; import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail'; import { tailTrunc } from './truncate'; -function turnItem(n: number): TranscriptTurn { +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +let tick = 0; + +function ts(): number { + tick += 1; + return T0 + tick * 1000; +} + +function turnMsg(n: number, status: 'running' | 'completed' = 'completed'): TurnMessage { return { - kind: 'turn', - turnId: `t${n}`, + type: 'turn', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + turn_id: `t${n}`, ordinal: n, - state: 'completed', + status, origin: { kind: 'user' }, - steps: [], }; } -function stateWith(items: readonly TranscriptTurn[]): AgentState { - return { ...EMPTY_AGENT_STATE, items }; +function stepMsg(stepId: string, status: 'running' | 'completed'): StepMessage { + return { + type: 'step', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + step_id: stepId, + turn_id: stepId.split('.')[0] ?? 't1', + ordinal: Number(stepId.split('.')[1] ?? '1'), + status, + }; +} + +function stateWithTimeline(items: readonly (TurnMessage | StepMessage)[]): ChatState { + return { + ...EMPTY_CHAT_STATE, + entries: items.map((message) => ({ + key: message.type === 'turn' ? `turn:${message.turn_id}` : `step:${message.step_id}`, + message, + })), + }; } // ---------------------------------------------------------------- diff @@ -53,12 +83,14 @@ describe('diffValue', () => { }); it('matches entity arrays by id instead of index', () => { - const prev = [turnItem(1), turnItem(2)]; - const next = [turnItem(1), { ...turnItem(2), state: 'running' as const }, turnItem(3)]; + const t1 = turnMsg(1); + const t2 = turnMsg(2); + const prev = [t1, t2]; + const next = [t1, { ...t2, status: 'running' as const }, turnMsg(3)]; const node = diffValue(prev, next); expect(node.children?.get('t1')?.status).toBe('unchanged'); expect(node.children?.get('t2')?.status).toBe('modified'); - expect(node.children?.get('t2')?.children?.get('state')).toMatchObject({ + expect(node.children?.get('t2')?.children?.get('status')).toMatchObject({ status: 'modified', prev: 'completed', value: 'running', @@ -66,18 +98,11 @@ describe('diffValue', () => { expect(node.children?.get('t3')?.status).toBe('added'); }); - it('keys steps by stepId (not their shared turnId) so siblings never collide', () => { - const step = (id: string, state: 'running' | 'completed') => ({ - kind: 'step' as const, - stepId: id, - turnId: 't1', - ordinal: 1, - state, - frames: [], - }); + it('keys steps by step_id (not their shared turn_id) so siblings never collide', () => { + const done = stepMsg('t1.1', 'completed'); const node = diffValue( - [step('t1.1', 'completed'), step('t1.2', 'completed')], - [step('t1.1', 'completed'), step('t1.2', 'running')], + [done, stepMsg('t1.2', 'completed')], + [done, stepMsg('t1.2', 'running')], ); expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']); expect(node.children?.get('t1.1')?.status).toBe('unchanged'); @@ -85,7 +110,8 @@ describe('diffValue', () => { }); it('marks removed array elements by id', () => { - const node = diffValue([turnItem(1), turnItem(2)], [turnItem(2)]); + const t2 = turnMsg(2); + const node = diffValue([turnMsg(1), t2], [t2]); expect(node.children?.get('t1')).toMatchObject({ status: 'removed' }); expect(node.children?.get('t2')?.status).toBe('unchanged'); }); @@ -105,48 +131,69 @@ describe('diffValue', () => { expect(diffValue([1], { 0: 1 }).status).toBe('modified'); }); - it('diffs two serialized states with meta changes visible (goal/plan fields)', () => { - const prev = serializeState(stateWith([turnItem(1)])); - const nextState: AgentState = { - ...stateWith([turnItem(1)]), - meta: { + it('diffs two serialized states with session.state changes visible', () => { + const base = stateWithTimeline([turnMsg(1)]); + const prev = serializeState(base); + const nextState: ChatState = { + ...base, + sessionState: { + type: 'session.state', + session_id: 's1', + timestamp: ts(), + status: 'running', goal: { objective: 'ship it', status: 'active' }, - modes: { plan: { reviewPath: '/tmp/plan.md' } }, + modes: { plan: { review_path: '/tmp/plan.md' } }, }, }; const node: DiffNode = diffValue(prev, serializeState(nextState)); - expect(node.children?.get('items')?.status).toBe('unchanged'); - const meta = node.children?.get('meta'); - expect(meta?.status).toBe('modified'); - expect(meta?.children?.get('goal')?.status).toBe('added'); - // Whole-subtree add: `modes` was absent before, so the block (plan - // included) is marked added without descending into children. - expect(meta?.children?.get('modes')?.status).toBe('added'); - expect(meta?.children?.get('modes')?.children).toBeUndefined(); + expect(node.children?.get('timeline')?.status).toBe('unchanged'); + const sessionState = node.children?.get('sessionState'); + expect(sessionState?.status).toBe('added'); + expect(sessionState?.children).toBeUndefined(); }); }); // ---------------------------------------------------------------- serialize describe('serializeState', () => { - it('turns maps into sorted plain objects and sets into arrays', () => { - const state: AgentState = { - ...EMPTY_AGENT_STATE, + it('turns maps into sorted plain objects and flattens the timeline', () => { + const state: ChatState = { + ...EMPTY_CHAT_STATE, + entries: stateWithTimeline([turnMsg(1)]).entries, tasks: new Map([ [ 'b-task', - { taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' }, + { + type: 'task', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + task_id: 'b-task', + kind: 'shell', + status: 'running', + detached: false, + output_tail: '', + }, ], [ 'a-task', - { taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' }, + { + type: 'task', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + task_id: 'a-task', + kind: 'tool', + status: 'completed', + detached: false, + output_tail: '', + }, ], ]), - pendingInteractions: new Set(['z', 'a']), }; const out = serializeState(state); - expect(Object.keys(out.tasks as Record)).toEqual(['a-task', 'b-task']); - expect(out.pendingInteractions).toEqual(['a', 'z']); + expect(Object.keys(out.tasks)).toEqual(['a-task', 'b-task']); + expect(out.timeline.map((m) => (m.type === 'turn' ? m.turn_id : ''))).toEqual(['t1']); expect(out.hasMoreOlder).toBe(false); }); }); @@ -171,37 +218,20 @@ describe('tailTrunc', () => { // ---------------------------------------------------------------- trail describe('AuditTrail', () => { - const page = { - items: [turnItem(1)], - hasMoreOlder: false, - tasks: [], - interactions: [], - attachments: [], - todos: [], - meta: {}, - pendingInteractions: [], - }; - it('records entries with increasing indices, timestamps, and state references', () => { const trail = new AuditTrail(); - const s1 = stateWith([turnItem(1)]); - const s2 = stateWith([turnItem(1), turnItem(2)]); - trail.recordRest({ pageSize: 30 }, 'replace', page, s1); - trail.recordOps([{ op: 'turn.upsert', turn: turnItem(2) }], 'live', '2026-01-01T00:00:00Z', s2); + const s1 = stateWithTimeline([turnMsg(1)]); + const s2 = stateWithTimeline([turnMsg(1), turnMsg(2)]); + trail.recordRest({ pageSize: 500 }, 'replace', 1, { turn_id: 't1', step_id: 't1.1' }, s1); + trail.recordWs(turnMsg(2, 'running'), s2); trail.recordEvent('prompt', 'hello', s2); - trail.recordReset( - { items: [], tasks: [], interactions: [], attachments: [], todos: [], prompts: [], meta: {} }, - false, - undefined, - s2, - ); const entries = trail.getEntries(); - expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ops', 'event', 'reset']); - expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3]); + expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ws', 'event']); + expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2]); expect(entries[0]!.state).toBe(s1); expect(entries[1]!.state).toBe(s2); - expect(entries[1]).toMatchObject({ delivery: 'live', envelopeAt: '2026-01-01T00:00:00Z' }); + expect(entries[0]).toMatchObject({ mode: 'replace', messageCount: 1 }); expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' }); expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe( true, @@ -215,18 +245,18 @@ describe('AuditTrail', () => { const unsubscribe = trail.subscribe(() => { notified += 1; }); - trail.recordEvent('cancel', undefined, EMPTY_AGENT_STATE); - trail.recordEvent('gap', undefined, EMPTY_AGENT_STATE); + trail.recordEvent('cancel', undefined, EMPTY_CHAT_STATE); + trail.recordEvent('ack', undefined, EMPTY_CHAT_STATE); expect(notified).toBe(2); unsubscribe(); - trail.recordEvent('resync', undefined, EMPTY_AGENT_STATE); + trail.recordEvent('reconnect', undefined, EMPTY_CHAT_STATE); expect(notified).toBe(2); }); it('drops the oldest entries beyond the cap while indices keep increasing', () => { const trail = new AuditTrail(); for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) { - trail.recordEvent('prompt', `p${i}`, EMPTY_AGENT_STATE); + trail.recordEvent('prompt', `p${i}`, EMPTY_CHAT_STATE); } const entries = trail.getEntries(); expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES); diff --git a/apps/kimi-inspect/src/audit/diff.ts b/apps/kimi-inspect/src/audit/diff.ts index 613d7ebe6e6..13c1af72e1b 100644 --- a/apps/kimi-inspect/src/audit/diff.ts +++ b/apps/kimi-inspect/src/audit/diff.ts @@ -1,13 +1,14 @@ /** - * Structural diff over serialized `AgentState` values (see `serialize.ts`). + * Structural diff over serialized `ChatState` values (see `serialize.ts`). * * The audit panel diffs two adjacent, immutable states. Because the store * is copy-on-write, untouched subtrees share references — the reference * equality fast path below collapses them to `unchanged` without walking. * - * Arrays of transcript entities are matched by their id field (turnId, - * stepId, frameId, …) rather than by index, so an upsert in the middle of - * the timeline does not turn into a cascade of spurious modifications. + * Arrays of protocol entities are matched by their id field (turn_id, + * step_id, message_id, tool_call_id, …) rather than by index, so an upsert + * in the middle of the timeline does not turn into a cascade of spurious + * modifications. */ export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified'; @@ -27,21 +28,22 @@ export interface DiffNode { } /** - * Id fields checked in priority order — MOST SPECIFIC FIRST. A step carries - * both `turnId` and `stepId`, and a frame can carry `taskId` alongside its - * `frameId`; matching the wrong one mislabels the node and, worse, collides - * siblings in the children map (two steps of one turn both keyed `t1`). + * Id fields checked in priority order — MOST SPECIFIC FIRST. An interaction + * carries both `interaction_id` and `tool_call_id`, a tool call can carry + * `task_id` / `todo_id` alongside its `tool_call_id`, and every timeline + * entity carries `turn_id`; matching the wrong one mislabels the node and, + * worse, collides siblings in the children map (two tool calls of one task + * both keyed by that task id). */ const ID_FIELDS = [ - 'frameId', - 'stepId', - 'interactionId', - 'attachmentId', - 'todoId', - 'markerId', - 'refId', - 'turnId', - 'taskId', + 'message_id', + 'interaction_id', + 'tool_call_id', + 'task_id', + 'todo_id', + 'system_id', + 'step_id', + 'turn_id', ] as const; function elementId(element: unknown): string | undefined { diff --git a/apps/kimi-inspect/src/audit/serialize.ts b/apps/kimi-inspect/src/audit/serialize.ts index ddcbecea3c8..1b2768b52e6 100644 --- a/apps/kimi-inspect/src/audit/serialize.ts +++ b/apps/kimi-inspect/src/audit/serialize.ts @@ -1,48 +1,43 @@ /** - * Serialize an `AgentState` into a plain, JSON-shaped object for the audit + * Serialize a `ChatState` into a plain, JSON-shaped object for the audit * panel's state tree and structural diff. Maps become key-sorted plain - * objects (stable display order), Sets become sorted arrays; everything - * else is passed through by reference (state is immutable, so sharing is - * safe and keeps the reference-equality fast path in `diffValue` useful). + * objects (stable display order); everything else is passed through by + * reference (state is immutable, so sharing is safe and keeps the + * reference-equality fast path in `diffValue` useful). */ import type { - AgentState, - TranscriptAttachment, - TranscriptInteraction, - TranscriptItem, - TranscriptMeta, - TranscriptTask, - TranscriptTodo, -} from '@moonshot-ai/transcript'; + InteractionMessage, + SessionStateMessage, + TaskMessage, + TodoMessage, +} from '@moonshot-ai/kap-server/protocol'; -/** Plain-object view of an `AgentState` (Maps/Sets unwrapped). */ -export interface SerializedAgentState { - readonly items: readonly TranscriptItem[]; - readonly tasks: Record; - readonly interactions: Record; - readonly attachments: Record; - readonly todos: Record; - readonly meta: TranscriptMeta; - readonly pendingInteractions: readonly string[]; +import type { ChatState, TimelineMessage } from '../transcript/store'; + +/** Plain-object view of a `ChatState` (Maps unwrapped). */ +export interface SerializedChatState { + readonly timeline: readonly TimelineMessage[]; + readonly interactions: Record; + readonly tasks: Record; + readonly todos: Record; + readonly sessionState: SessionStateMessage | undefined; readonly hasMoreOlder: boolean; } function mapToSortedObject(map: ReadonlyMap): Record { const out: Record = {}; - for (const key of [...map.keys()].sort()) out[key] = map.get(key) as V; + for (const key of [...map.keys()].toSorted()) out[key] = map.get(key) as V; return out; } -export function serializeState(state: AgentState): SerializedAgentState { +export function serializeState(state: ChatState): SerializedChatState { return { - items: state.items, - tasks: mapToSortedObject(state.tasks), + timeline: state.entries.map((entry) => entry.message), interactions: mapToSortedObject(state.interactions), - attachments: mapToSortedObject(state.attachments), + tasks: mapToSortedObject(state.tasks), todos: mapToSortedObject(state.todos), - meta: state.meta, - pendingInteractions: [...state.pendingInteractions].sort(), + sessionState: state.sessionState, hasMoreOlder: state.hasMoreOlder, }; } diff --git a/apps/kimi-inspect/src/audit/trail.ts b/apps/kimi-inspect/src/audit/trail.ts index efed4cfd2b7..3d69159bb7b 100644 --- a/apps/kimi-inspect/src/audit/trail.ts +++ b/apps/kimi-inspect/src/audit/trail.ts @@ -1,21 +1,17 @@ /** - * Audit trail for the chat view's transcript channel. + * Audit trail for the chat view's message-protocol channel. * - * A pure observer: the chat pipeline (REST loads, WS frames, user actions) - * calls the `record*` methods AFTER applying each step to the real - * `TranscriptChatStore`, passing the resulting immutable `AgentState` - * reference. Replaying the trail is therefore free — every entry already - * holds the exact state the store had at that point, ready for the - * timeline slider and the structural diff. + * A pure observer: the chat pipeline (REST history loads, WS messages, user + * actions) calls the `record*` methods AFTER applying each step to the real + * `ChatStore`, passing the resulting immutable `ChatState` reference. + * Replaying the trail is therefore free — every entry already holds the + * exact state the store had at that point, ready for the timeline slider + * and the structural diff. */ -import type { - AgentState, - AgentTranscriptSnapshot, - TranscriptOperation, -} from '@moonshot-ai/transcript'; +import type { ServerMessage } from '@moonshot-ai/kap-server/protocol'; -import type { TranscriptPage } from '../transcript/api'; +import type { ChatState } from '../transcript/store'; export const AUDIT_TRAIL_MAX_ENTRIES = 5000; @@ -25,53 +21,52 @@ interface AuditEntryBase { /** Local record time (ISO). */ readonly at: string; /** Store state right after this entry was applied (immutable reference). */ - readonly state: AgentState; + readonly state: ChatState; /** One-line summary for the timeline list. */ readonly summary: string; } export interface RestAuditEntry extends AuditEntryBase { readonly kind: 'rest'; - readonly request: { readonly beforeTurn?: string | undefined; readonly pageSize: number }; - readonly appliedAs: 'replace' | 'prepend'; - readonly page: TranscriptPage; -} - -export interface OpsAuditEntry extends AuditEntryBase { - readonly kind: 'ops'; - /** Envelope timestamp (server send time) when present. */ - readonly envelopeAt?: string | undefined; - readonly ops: readonly TranscriptOperation[]; - /** live = applied immediately; buffered = held during a REST refresh; flushed = replayed after one; catchup = fetched via the ops catch-up endpoint after a seq gap. */ - readonly delivery: 'live' | 'buffered' | 'flushed' | 'catchup'; + readonly request: { + readonly beforeTurn?: string | undefined; + readonly afterStep?: string | undefined; + readonly pageSize: number; + }; + /** replace = newest page (initial/refresh); prepend = older page; tail = after_step catch-up. */ + readonly mode: 'replace' | 'prepend' | 'tail'; + readonly messageCount: number; + readonly inFlight?: { turn_id: string; step_id: string } | undefined; } -export interface ResetAuditEntry extends AuditEntryBase { - readonly kind: 'reset'; - readonly envelopeAt?: string | undefined; - readonly snapshot: AgentTranscriptSnapshot; - readonly hasMoreOlder: boolean; +export interface WsAuditEntry extends AuditEntryBase { + readonly kind: 'ws'; + /** The raw server message as applied to the store (entity, delta, or state). */ + readonly message: ServerMessage; } export interface EventAuditEntry extends AuditEntryBase { readonly kind: 'event'; - readonly event: 'ack-refresh' | 'resync' | 'gap' | 'prompt' | 'cancel'; + readonly event: + | 'ack' + | 'ack-error' + | 'reconnect' + | 'catchup-refresh' + | 'protocol-error' + | 'invalid-frame' + | 'prompt' + | 'cancel' + | 'older-error'; readonly detail?: string | undefined; } -export type AuditEntry = RestAuditEntry | OpsAuditEntry | ResetAuditEntry | EventAuditEntry; +export type AuditEntry = RestAuditEntry | WsAuditEntry | EventAuditEntry; type DistributiveOmit = T extends unknown ? Omit : never; /** Entry payload accepted by `push` (index/at are filled in there). */ type AuditEntryInput = DistributiveOmit; -function summarizeOps(ops: readonly TranscriptOperation[]): string { - const counts = new Map(); - for (const op of ops) counts.set(op.op, (counts.get(op.op) ?? 0) + 1); - return [...counts.entries()].map(([name, n]) => (n > 1 ? `${name}×${n}` : name)).join(', '); -} - export class AuditTrail { private entryList: AuditEntry[] = []; private nextIndex = 0; @@ -91,68 +86,61 @@ export class AuditTrail { recordRest( request: RestAuditEntry['request'], - appliedAs: RestAuditEntry['appliedAs'], - page: TranscriptPage, - state: AgentState, + mode: RestAuditEntry['mode'], + messageCount: number, + inFlight: RestAuditEntry['inFlight'], + state: ChatState, ): void { - const cursor = request.beforeTurn !== undefined ? `?before_turn=${request.beforeTurn}` : ''; + const cursor = + request.beforeTurn !== undefined + ? `?before_turn=${request.beforeTurn}` + : request.afterStep !== undefined + ? `?after_step=${request.afterStep}` + : ''; + const flight = inFlight !== undefined ? ` (in_flight ${inFlight.step_id})` : ''; this.push({ kind: 'rest', request, - appliedAs, - page, + mode, + messageCount, + inFlight, state, - summary: `GET transcript${cursor} → ${page.items.length} items (${appliedAs})`, + summary: `GET history${cursor} → ${messageCount} messages (${mode})${flight}`, }); } - recordOps( - ops: readonly TranscriptOperation[], - delivery: OpsAuditEntry['delivery'], - envelopeAt: string | undefined, - state: AgentState, - ): void { + recordWs(message: ServerMessage, state: ChatState): void { this.push({ - kind: 'ops', - ops, - delivery, - envelopeAt, + kind: 'ws', + message, state, - summary: `${ops.length} ops (${summarizeOps(ops)}) [${delivery}]`, - }); - } - - recordReset( - snapshot: AgentTranscriptSnapshot, - hasMoreOlder: boolean, - envelopeAt: string | undefined, - state: AgentState, - ): void { - this.push({ - kind: 'reset', - snapshot, - hasMoreOlder, - envelopeAt, - state, - summary: `reset snapshot (${snapshot.items.length} items) — ignored by chat store`, + summary: summarizeMessage(message), }); } recordEvent( event: EventAuditEntry['event'], detail: string | undefined, - state: AgentState, + state: ChatState, ): void { const label = - event === 'ack-refresh' - ? 'subscribe ack → REST refresh' - : event === 'resync' - ? 'resync_required → REST refresh' - : event === 'gap' - ? 'append gap → REST refresh' - : event === 'prompt' - ? 'prompt sent' - : 'cancel sent'; + event === 'ack' + ? 'subscribe ack → after_step catch-up' + : event === 'ack-error' + ? 'subscribe ack error' + : event === 'reconnect' + ? 'socket dropped → reconnecting' + : event === 'catchup-refresh' + ? 'catch-up anchor gone → full refresh' + : event === 'protocol-error' + ? 'protocol error frame' + : event === 'invalid-frame' + ? 'invalid frame (server bug)' + : event === 'prompt' + ? 'prompt sent' + : event === 'cancel' + ? 'cancel sent' + : 'older-page load failed'; this.push({ kind: 'event', event, @@ -173,3 +161,38 @@ export class AuditTrail { for (const listener of this.listeners) listener(); } } + +function summarizeMessage(message: ServerMessage): string { + switch (message.type) { + case 'turn': + return `turn ${message.turn_id} (${message.status})`; + case 'step': + return `step ${message.step_id} (${message.status})`; + case 'user': + return `user ${message.message_id}`; + case 'assistant': + case 'thinking': + return `${message.type} ${message.message_id} (${message.status})`; + case 'assistant.delta': + case 'thinking.delta': + return `${message.type} ${message.message_id} +${message.text.length}ch`; + case 'tool_call': + return `tool_call ${message.name} ${message.tool_call_id} (${message.status})`; + case 'tool_call.delta': + return `tool_call.delta ${message.tool_call_id} +${message.input_text.length}ch`; + case 'tool.progress': + return `tool.progress ${message.tool_call_id} (${message.progress.kind})`; + case 'system': + return `system(${message.subtype}) ${message.system_id}`; + case 'interaction': + return `interaction ${message.interaction_id} (${message.kind}/${message.status})`; + case 'task': + return `task ${message.task_id} (${message.kind}/${message.status})`; + case 'todo': + return `todo ${message.todo_id} (${message.items.length} items)`; + case 'session.state': + return `session.state (${message.status})`; + default: + return message.type; + } +} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index 449fc0b9115..34e3faf0f16 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -1,58 +1,53 @@ /** * Main view — the conversation of the active session + agent, rendered from - * the transcript surface (`/api/v1`): + * the message protocol (`/api/v3/ws` + `GET /api/v1/sessions/{id}/history`): * - * - FULL state comes from the REST transcript API only: the initial load - * reads the newest page, a full refresh re-reads from the tail backwards - * until the previously loaded window is re-covered, and "Load earlier - * turns" pages further with a `before_turn` cursor. - * - The WS channel (`/api/v1/ws`) is a DELTA channel only: `transcript.ops` - * at `delta` grade; `transcript.reset` snapshots are ignored. Ops are - * buffered while a REST refresh is in flight and flushed onto the fresh - * pages — idempotent upserts and offset-placed appends make that converge. - * - Loss signals (`resync_required`, append gap, socket reconnect) trigger - * a full REST refresh; nothing is resynced from the socket itself. + * - Persisted state comes from the REST history endpoint only: the initial + * load reads the newest page, a full refresh re-reads it and re-covers + * the previously loaded window, and "load earlier" pages further with a + * `before_turn` cursor. + * - The WS channel carries the recovery payload and all live traffic; both + * are applied to the store through the same idempotent replace-by-id + * path (delta family appended by id, entity content authoritative), so + * there is no reset/buffer/cursor machinery. + * - Every subscribe ack (initial and reconnect) triggers an `after_step` + * catch-up from the newest terminal step; an empty catch-up whose + * anchor vanished (undo/clear while away) falls back to a full refresh. * - * Rendering is turn-granular (turn → step → frame) and typed entirely by the - * transcript data model. Prompts/cancels go through the `IAgentPromptService` - * / `IAgentLoopService` channels - * over the debug RPC surface (`/api/v1/debug`); interaction answers - * (approve/reject, answer/dismiss) go through the public REST endpoints - * (`src/interactions/api.ts`); the running indicator - * derives from transcript state (`meta.activity` / running turns). + * Rendering groups the flat timeline by turn (system markers stay + * standalone) and is typed entirely by the protocol schemas + * (`@moonshot-ai/kap-server/protocol`). Prompts/cancels go through the + * `IAgentPromptService` / `IAgentLoopService` channels over the debug RPC + * surface (`/api/v1/debug`); interaction answers (approve/reject, + * answer/dismiss) go through the public REST endpoints + * (`src/interactions/api.ts`); the running indicator derives from + * `session.state`. */ import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; -import { - type QuestionItem, - type QuestionRequest, -} from '@moonshot-ai/agent-core-v2/agent/interaction/question'; -import { - EMPTY_AGENT_STATE, - itemId, - type AgentState, - type NoticeFrame, - type ToolCallFrame, - type TranscriptAttachment, - type TranscriptFrame, - type TranscriptInteraction, - type TranscriptItem, - type TranscriptMarker, - type TranscriptOperation, - type TranscriptTask, - type TranscriptTaskRef, - type TranscriptTurn, - type TranscriptUsage, - type TurnOrigin, - type TurnState, -} from '@moonshot-ai/transcript'; +import type { + AssistantMessage, + ContentPart, + InteractionMessage, + InteractionQuestionItem, + SessionStateMessage, + StepMessage, + SystemMessage, + TaskMessage, + ThinkingMessage, + TodoMessage, + ToolCallMessage, + TurnMessage, + UserMessage, +} from '@moonshot-ai/kap-server/protocol'; import { createContext, useCallback, useContext, useEffect, useLayoutEffect, + useMemo, useRef, useState, useSyncExternalStore, @@ -67,20 +62,13 @@ import { type QuestionAnswerWire, } from '../interactions/api'; import type { SearchHit } from '../search/api'; +import { ChatChannel } from '../transcript/channel'; import { - fetchTranscriptAttachment, - fetchTranscriptOps, - fetchTranscriptPage, - TRANSCRIPT_PAGE_SIZE, -} from '../transcript/api'; -import { - createCoalescedRunner, + EMPTY_CHAT_STATE, hasTurnId, - oldestTurnId, - recoverLoadedWindow, - TranscriptChatStore, + type ChatState, + type TimelineEntry, } from '../transcript/store'; -import { TranscriptWs } from '../transcript/ws'; import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui'; import { ChatSearchBar } from './ChatSearchBar'; @@ -103,251 +91,71 @@ export interface ChatJump { readonly nonce: number; } -interface TranscriptChannel { - /** Null until the effect has created the store (pre-ready / no session). */ - readonly store: TranscriptChatStore | null; - readonly state: AgentState; +interface ChatChannelState { + /** Null until the effect has created the channel (pre-ready / no session). */ + readonly channel: ChatChannel | null; + readonly state: ChatState; /** Records every step that built the store (audit panel data source). */ readonly trail: AuditTrail | null; /** True once the initial REST page load succeeded. */ readonly loaded: boolean; - /** Set when the initial/refresh load failed (e.g. server without transcript). */ + /** Set when the initial/refresh load failed. */ readonly loadError: unknown; } /** - * Owns the store, the REST load/refresh pipeline, and the WS delta - * subscription for one (sessionId, agentId) pair. + * Owns the channel (store + REST + WS) for one (sessionId, agentId) pair. */ -function useTranscriptChannel( +function useChatChannel( sessionId: string | null, agentId: string, ready: boolean, captureAnchor: () => void, -): TranscriptChannel { +): ChatChannelState { const { baseUrl, config } = useConnection(); const token = config.token.trim(); - const [channel, setChannel] = useState<{ store: TranscriptChatStore; trail: AuditTrail } | null>( - null, - ); + const [channel, setChannel] = useState(null); const [loaded, setLoaded] = useState(false); const [loadError, setLoadError] = useState(null); useEffect(() => { if (!ready || sessionId === null) return; - const store = new TranscriptChatStore(); - const trail = new AuditTrail(); const authToken = token === '' ? undefined : token; - let disposed = false; - /** While a REST reload / catch-up is in flight, WS ops are buffered, then flushed. */ - let fetching = true; - let buffer: TranscriptOperation[] = []; - /** Max batch seq seen while buffering (folded into the watermark on flush). */ - let bufferedSeq: number | undefined; - /** - * Op-batch watermark: the store is known to include every batch with - * seq <= lastSeq. Sourced from REST page watermarks and applied batch - * seqs; `undefined` until a sequenced server provides one (legacy - * servers never do — every recovery then falls back to full refreshes). - */ - let lastSeq: number | undefined; - /** Cursor of the in-flight recover fetch, paired with `onPageApplied`. */ - let recoverBefore: string | undefined; - /** True once the initial page load succeeded (gates reset-driven catch-up). */ - let seeded = false; - - const noteSeq = (seq: number | undefined): void => { - if (seq === undefined) return; - lastSeq = lastSeq === undefined ? seq : Math.max(lastSeq, seq); - }; - - const flushBuffer = (): void => { - fetching = false; - if (buffer.length > 0) { - const flushed = buffer; - store.applyOps(flushed); - trail.recordOps(flushed, 'flushed', undefined, store.getState()); - noteSeq(bufferedSeq); - } - buffer = []; - bufferedSeq = undefined; - }; - - /** Page (re)load body shared by the full refresh and the catch-up fallback. */ - const reloadPages = async (): Promise => { - // The window's oldest turn is the re-cover anchor: after a refresh the - // server window may have shifted, and only re-loading up to THIS turn - // preserves the previously loaded history. - const prevOldest = oldestTurnId(store.getState().items); - if (prevOldest !== undefined) captureAnchor(); - const newest = await fetchTranscriptPage({ - baseUrl, - token: authToken, - sessionId, - agentId, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - if (disposed) return; - store.applyPage(newest, { replace: true }); - trail.recordRest({ pageSize: TRANSCRIPT_PAGE_SIZE }, 'replace', newest, store.getState()); - lastSeq = newest.seq; - // Re-cover the previously loaded window for refreshes (a no-op on the - // initial load, where there is no previous oldest turn). - await recoverLoadedWindow( - store, - prevOldest, - (beforeTurn) => { - recoverBefore = beforeTurn; - return fetchTranscriptPage({ - baseUrl, - token: authToken, - sessionId, - agentId, - beforeTurn, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - }, - () => disposed, - (page) => { - trail.recordRest( - { beforeTurn: recoverBefore, pageSize: TRANSCRIPT_PAGE_SIZE }, - 'prepend', - page, - store.getState(), - ); - }, - ); - if (!disposed) { - seeded = true; - setLoaded(true); - setLoadError(null); - } - }; - - /** Full-state (re)load: the legacy recovery path and the initial load. */ - const refresh = createCoalescedRunner(async (): Promise => { - fetching = true; - buffer = []; - bufferedSeq = undefined; - try { - await reloadPages(); - } catch (error) { - if (!disposed) setLoadError(error); - } finally { - flushBuffer(); - } - }); - - /** - * Targeted catch-up: fetch exactly the op batches after our watermark - * (`GET .../transcript/ops?since_seq=`). Falls back to a full page - * reload on a legacy server (no seq / endpoint missing), a journal that - * no longer covers the gap (`complete: false`), or a fetch failure. - */ - const catchUp = createCoalescedRunner(async (): Promise => { - if (lastSeq === undefined) { - refresh(); - return; - } - fetching = true; - buffer = []; - bufferedSeq = undefined; - try { - const res = await fetchTranscriptOps({ - baseUrl, - token: authToken, - sessionId, - agentId, - sinceSeq: lastSeq, - }); - if (disposed) return; - if (!res.complete) { - await reloadPages(); - } else { - for (const batch of res.batches) { - store.applyOps(batch.ops); - trail.recordOps(batch.ops, 'catchup', undefined, store.getState()); - } - noteSeq(res.latestSeq); - } - } catch { - try { - await reloadPages(); - } catch (error) { - if (!disposed) setLoadError(error); - } - } finally { - flushBuffer(); - } - }); - - const ws = new TranscriptWs({ - url: baseUrl, + const next = new ChatChannel({ + baseUrl, token: authToken, sessionId, agentId, - getSince: () => lastSeq, - handlers: { - onOps: (aid, ops, meta) => { - if (aid !== agentId) return; - if (fetching) { - buffer.push(...ops); - if (meta?.seq !== undefined) { - bufferedSeq = Math.max(bufferedSeq ?? 0, meta.seq); - } - trail.recordOps(ops, 'buffered', meta?.at, store.getState()); - return; - } - // Seq gap: the store is behind by at least one batch. Catch up - // point-to-point instead of applying on a stale base (appends are - // offset-placed and would surface a gap anyway). - if (meta?.seq !== undefined && lastSeq !== undefined && meta.seq > lastSeq + 1) { - catchUp(); - return; - } - store.applyOps(ops); - trail.recordOps(ops, 'live', meta?.at, store.getState()); - noteSeq(meta?.seq); - }, - onReset: (_aid, snapshot, hasMoreOlder, meta) => { - trail.recordReset(snapshot, hasMoreOlder, meta?.at, store.getState()); - // Sequenced mode only: a reset after seeding means the server could - // not replay from our `transcript_since` cursor (journal truncated) - // — catch up, which itself falls back to a full reload when the seq - // window is gone. On legacy servers (no watermark) resets are - // routine per-subscribe noise and stay ignored, as before. - if (seeded && lastSeq !== undefined) catchUp(); - }, - onResyncRequired: () => { - trail.recordEvent('resync', undefined, store.getState()); - catchUp(); - }, - onReconnected: () => { - trail.recordEvent('ack-refresh', undefined, store.getState()); - catchUp(); - }, + onWillReplace: captureAnchor, + onLoaded: () => { + setLoaded(true); + setLoadError(null); + }, + onLoadError: (error) => { + setLoadError(error); }, }); - store.onGap = () => { - trail.recordEvent('gap', undefined, store.getState()); - catchUp(); - }; - setChannel({ store, trail }); + setChannel(next); setLoaded(false); setLoadError(null); - refresh(); + next.start(); return () => { - disposed = true; - ws.close(); + next.close(); setChannel(null); }; }, [sessionId, agentId, ready, baseUrl, token, captureAnchor]); const state = useSyncExternalStore( channel?.store.subscribe ?? noopSubscribe, - () => channel?.store.getState() ?? EMPTY_AGENT_STATE, + () => channel?.store.getState() ?? EMPTY_CHAT_STATE, ); - return { store: channel?.store ?? null, state, trail: channel?.trail ?? null, loaded, loadError }; + return { + channel, + state, + trail: channel?.trail ?? null, + loaded, + loadError, + }; } export function ChatView({ @@ -371,7 +179,7 @@ export function ChatView({ /** Hands an in-chat search hit up to the app shell (agent switch + jump). */ onOpenSearchHit?: ((hit: SearchHit) => void) | undefined; }) { - const { klient, baseUrl, config } = useConnection(); + const { klient } = useConnection(); const [input, setInput] = useState(''); const [sendError, setSendError] = useState(null); const [loadingOlder, setLoadingOlder] = useState(false); @@ -389,13 +197,13 @@ export function ChatView({ if (el !== null) anchorRef.current = el.scrollHeight - el.scrollTop; }, []); - const { store, state, trail, loaded, loadError } = useTranscriptChannel( + const { channel, state, trail, loaded, loadError } = useChatChannel( sessionId, agentId, ready, captureAnchor, ); - const items = state.items; + const entries = state.entries; // The audit panel is rendered by the app shell's right dock; report the // trail (null while no channel exists) so it can subscribe to it there. @@ -408,7 +216,7 @@ export function ChatView({ // step (or the turn card) and flash it briefly. A turn that never appears // (cut by an undo) degrades to no scroll. useEffect(() => { - if (jump === null || jump === undefined || !loaded || store === null || sessionId === null) { + if (jump === null || jump === undefined || !loaded || channel === null || sessionId === null) { return; } if (jump.turnId === undefined) { @@ -416,38 +224,27 @@ export function ChatView({ return; } let cancelled = false; + const isCancelled = (): boolean => cancelled; const turnId = jump.turnId; const stepId = jump.stepId; void (async () => { stickBottomRef.current = false; - const token = config.token.trim(); - let recoverBefore: string | undefined; - await recoverLoadedWindow( - store, - turnId, - (beforeTurn) => { - recoverBefore = beforeTurn; - return fetchTranscriptPage({ - baseUrl, - token: token === '' ? undefined : token, - sessionId, - agentId, - beforeTurn, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - }, - () => cancelled, - (page) => { - trail?.recordRest( - { beforeTurn: recoverBefore, pageSize: TRANSCRIPT_PAGE_SIZE }, - 'prepend', - page, - store.getState(), - ); - }, - ); + const store = channel.store; + try { + while ( + !hasTurnId(store.getState().entries, turnId) && + store.getState().hasMoreOlder && + !isCancelled() + ) { + const before = store.getState().entries.length; + await channel.loadOlder(); + if (store.getState().entries.length === before) break; + } + } catch { + // A failed older-page load leaves the window as-is; degrade to no scroll. + } if (cancelled) return; - if (!hasTurnId(store.getState().items, turnId)) { + if (!hasTurnId(store.getState().entries, turnId)) { onJumpHandled?.(); return; } @@ -470,7 +267,7 @@ export function ChatView({ return () => { cancelled = true; }; - }, [jump, loaded, store, sessionId, agentId, baseUrl, config, trail, onJumpHandled]); + }, [jump, loaded, channel, sessionId, onJumpHandled]); // The flash highlight clears itself after a short moment. useEffect(() => { @@ -488,7 +285,7 @@ export function ChatView({ return; } if (stickBottomRef.current) el.scrollTop = el.scrollHeight; - }, [items]); + }, [entries]); const onScroll = () => { const el = scrollRef.current; @@ -497,32 +294,20 @@ export function ChatView({ }; const loadOlder = async () => { - if (sessionId === null || loadingOlder || store === null) return; - const oldest = oldestTurnId(items); - if (oldest === undefined) return; + if (channel === null || loadingOlder) return; captureAnchor(); setLoadingOlder(true); setOlderError(null); try { - const token = config.token.trim(); - const page = await fetchTranscriptPage({ - baseUrl, - token: token === '' ? undefined : token, - sessionId, - agentId, - beforeTurn: oldest, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - store.applyPage(page); - trail?.recordRest( - { beforeTurn: oldest, pageSize: TRANSCRIPT_PAGE_SIZE }, - 'prepend', - page, - store.getState(), - ); + await channel.loadOlder(); } catch (error) { anchorRef.current = null; setOlderError(error); + trail?.recordEvent( + 'older-error', + error instanceof Error ? error.message : String(error), + channel.store.getState(), + ); } finally { setLoadingOlder(false); } @@ -540,8 +325,8 @@ export function ChatView({ const root = scrollRef.current; if (sentinel === null || root === null || olderError !== null) return; const observer = new IntersectionObserver( - (entries) => { - if (entries.some((entry) => entry.isIntersecting)) void loadOlderRef.current(); + (observed) => { + if (observed.some((entry) => entry.isIntersecting)) void loadOlderRef.current(); }, { root, rootMargin: '400px 0px 0px 0px' }, ); @@ -552,18 +337,26 @@ export function ChatView({ }, [hasMoreOlder, loaded, olderError, loadingOlder]); const running = - state.meta.activity === 'turn' || - items.some((item) => item.kind === 'turn' && item.state === 'running'); + (state.sessionState !== undefined && state.sessionState.status !== 'idle') || + isAnyTurnRunning(entries); + const pendingCount = [...state.interactions.values()].filter( + (interaction) => interaction.status === 'pending', + ).length; - // Interactions render inline at their anchor tool frame; entities without - // an anchor (or whose anchor frame is outside the loaded window) collect - // here and render floating at the bottom. - const anchoredToolCallIds = collectToolCallIds(items); + // Interactions render inline at their anchor tool call; entities without + // an anchor (or whose anchor is outside the loaded window) collect here + // and render floating at the bottom. Unanchored tasks (no tool call + // references them, e.g. shell-command tasks) do the same. + const anchoredToolCallIds = useMemo(() => collectToolCallIds(entries), [entries]); const unanchoredInteractions = [...state.interactions.values()].filter( (interaction) => - interaction.toolCallId === undefined || !anchoredToolCallIds.has(interaction.toolCallId), + interaction.tool_call_id === undefined || !anchoredToolCallIds.has(interaction.tool_call_id), + ); + const anchoredTaskIds = useMemo(() => collectTaskIds(entries), [entries]); + const unanchoredTasks = [...state.tasks.values()].filter( + (task) => !anchoredTaskIds.has(task.task_id), ); - const latestTodo = [...state.todos.values()].at(-1); + const latestTodo = latestTodoOf(state.todos); const send = async () => { if (sessionId === null || input.trim() === '' || running) return; @@ -614,8 +407,9 @@ export function ChatView({ {sessionId} agent: {agentId} {running ? turn running : idle} - {state.pendingInteractions.size > 0 ? ( - {state.pendingInteractions.size} pending + {pendingCount > 0 ? {pendingCount} pending : null} + {state.sessionState !== undefined ? ( + ) : null} @@ -648,63 +442,25 @@ export function ChatView({
- Failed to load the transcript — the server may be too old to expose the transcript - API. + Failed to load the session history — the server may be too old to expose the + history API.
) : null} - {items.length === 0 && loadError === null ? ( + {entries.length === 0 && loadError === null ? (
{loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'}
) : null} {latestTodo !== undefined && latestTodo.items.length > 0 ? ( -
-
todo (latest)
- {latestTodo.items.map((entry, i) => ( -
- - {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} - - - {entry.title} - -
- ))} -
+ ) : null} - {items.map((item) => ( - // Native virtual screen: the browser skips layout/paint for - // off-screen items and remembers their last rendered size - // (`auto` in contain-intrinsic-size), so long transcripts stay - // cheap without a windowing library. -
- -
- ))} + {unanchoredInteractions.map((interaction) => ( - + + ))} + {unanchoredTasks.map((task) => ( + ))} @@ -742,447 +498,515 @@ export function ChatView({ ); } -// ---------------------------------------------------------------- items +// ---------------------------------------------------------------- timeline -function ItemView({ - item, - tasks, +type RenderItem = + | { + readonly kind: 'group'; + readonly turnId: string; + readonly turn: TurnMessage | undefined; + readonly items: readonly TimelineEntry[]; + } + | { readonly kind: 'system'; readonly key: string; readonly message: SystemMessage }; + +/** Pseudo-turn grouping queued (unread) user messages, which carry no turn_id yet. */ +const QUEUED_TURN_ID = '$queued'; + +function groupTimeline(entries: readonly TimelineEntry[]): RenderItem[] { + interface GroupDraft { + turn?: TurnMessage; + items: TimelineEntry[]; + } + const drafts = new Map(); + const order: ( + | { kind: 'group'; turnId: string } + | { kind: 'system'; key: string; message: SystemMessage } + )[] = []; + for (const entry of entries) { + const message = entry.message; + if (message.type === 'system') { + order.push({ kind: 'system', key: entry.key, message }); + continue; + } + const turnId = message.turn_id ?? QUEUED_TURN_ID; + let draft = drafts.get(turnId); + if (draft === undefined) { + draft = { items: [] }; + drafts.set(turnId, draft); + order.push({ kind: 'group', turnId }); + } + if (message.type === 'turn') draft.turn = message; + draft.items.push(entry); + } + return order.map((item) => + item.kind === 'system' + ? item + : { + kind: 'group', + turnId: item.turnId, + turn: drafts.get(item.turnId)?.turn, + items: drafts.get(item.turnId)?.items ?? [], + }, + ); +} + +function Timeline({ + items, interactions, - attachments, + tasks, flash, }: { - item: TranscriptItem; - tasks: ReadonlyMap; - interactions: ReadonlyMap; - attachments: ReadonlyMap; - /** The jump target being flashed, if any. */ + items: readonly TimelineEntry[]; + interactions: ReadonlyMap; + tasks: ReadonlyMap; flash?: { turnId: string; stepId?: string | undefined } | null | undefined; }) { - switch (item.kind) { - case 'turn': - return ( - - ); - case 'marker': - return ; - case 'taskref': - return ; + const renderItems = useMemo(() => groupTimeline(items), [items]); + return ( + <> + {renderItems.map((item) => + item.kind === 'system' ? ( + // Native virtual screen: the browser skips layout/paint for + // off-screen items and remembers their last rendered size. +
+ +
+ ) : ( +
+ +
+ ), + )} + + ); +} + +function isAnyTurnRunning(entries: readonly TimelineEntry[]): boolean { + return entries.some( + (entry) => entry.message.type === 'turn' && entry.message.status === 'running', + ); +} + +function collectToolCallIds(entries: readonly TimelineEntry[]): Set { + const ids = new Set(); + for (const entry of entries) { + if (entry.message.type === 'tool_call') ids.add(entry.message.tool_call_id); } + return ids; } -function collectToolCallIds(items: readonly TranscriptItem[]): Set { +function collectTaskIds(entries: readonly TimelineEntry[]): Set { const ids = new Set(); - for (const item of items) { - if (item.kind !== 'turn') continue; - for (const step of item.steps) { - for (const frame of step.frames) { - if (frame.kind === 'tool') ids.add(frame.toolCallId); - } + for (const entry of entries) { + if (entry.message.type === 'tool_call' && entry.message.task_id !== undefined) { + ids.add(entry.message.task_id); } } return ids; } -function turnStateTone(state: TurnState): 'neutral' | 'green' | 'amber' | 'red' { - switch (state) { - case 'running': - return 'amber'; - case 'completed': - return 'green'; - case 'failed': - return 'red'; - default: - return 'neutral'; +function latestTodoOf(todos: ReadonlyMap): TodoMessage | undefined { + let latest: TodoMessage | undefined; + for (const todo of todos.values()) { + if (latest === undefined || todo.timestamp > latest.timestamp) latest = todo; } + return latest; } -function usageText(usage: TranscriptUsage): string { - const parts: string[] = []; - if (usage.inputTokens !== undefined) parts.push(`in ${usage.inputTokens}`); - if (usage.outputTokens !== undefined) parts.push(`out ${usage.outputTokens}`); - if (usage.cachedTokens !== undefined) parts.push(`cached ${usage.cachedTokens}`); - if (usage.cost !== undefined) parts.push(`$${usage.cost.toFixed(4)}`); - return parts.join(' / '); -} +// ---------------------------------------------------------------- turn group -function TurnView({ +function TurnGroupView({ + turnId, turn, - tasks, + items, interactions, - attachments, + tasks, flash, }: { - turn: TranscriptTurn; - tasks: ReadonlyMap; - interactions: ReadonlyMap; - attachments: ReadonlyMap; - /** The jump target being flashed, if any. */ + turnId: string; + turn: TurnMessage | undefined; + items: readonly TimelineEntry[]; + interactions: ReadonlyMap; + tasks: ReadonlyMap; flash?: { turnId: string; stepId?: string | undefined } | null | undefined; }) { - const turnFlashed = flash?.turnId === turn.turnId && flash.stepId === undefined; + const turnFlashed = flash?.turnId === turnId && flash.stepId === undefined; + const queued = turnId === QUEUED_TURN_ID; return (
- {turn.turnId} - {turn.origin.kind} - {turn.state} - {turn.startedAt !== undefined ? ( - - {relTime(Date.parse(turn.startedAt))} - - ) : null} - {turn.usage !== undefined ? ( - {usageText(turn.usage)} - ) : null} + {queued ? ( + queued + ) : ( + {turnId} + )} + {turn !== undefined ? ( + <> + {turn.origin.kind} + {turn.status} + {turn.started_at !== undefined ? ( + + {relTime(Date.parse(turn.started_at))} + + ) : null} + {turn.usage !== undefined ? ( + + {turnUsageText(turn.usage)} + + ) : null} + + ) : queued ? ( + not consumed into a turn yet + ) : ( + turn header outside the window + )}
- {turn.prompt !== undefined && turn.prompt !== '' ? ( - + {turn?.attachment_ids !== undefined && turn.attachment_ids.length > 0 ? ( + ) : null} - {turn.attachmentIds !== undefined && turn.attachmentIds.length > 0 ? ( - - ) : null} - {turn.steps.map((step) => ( -
- {step.frames.map((frame) => ( - - ))} - {step.state === 'interrupted' ? ( -
step interrupted
- ) : null} -
+ {items.map((entry) => ( + ))}
); } -function TurnPrompt({ origin, prompt }: { origin: TurnOrigin; prompt: string }) { - if (origin.kind === 'user') { - return ( -
-
- {prompt} -
-
- ); +function turnUsageText(usage: NonNullable): string { + const parts: string[] = []; + if (usage.input_tokens !== undefined) parts.push(`in ${usage.input_tokens}`); + if (usage.output_tokens !== undefined) parts.push(`out ${usage.output_tokens}`); + if (usage.cached_tokens !== undefined) parts.push(`cached ${usage.cached_tokens}`); + if (usage.cost !== undefined) parts.push(`$${usage.cost.toFixed(4)}`); + return parts.join(' / '); +} + +function TimelineEntryView({ + entry, + interactions, + tasks, + flash, +}: { + entry: TimelineEntry; + interactions: ReadonlyMap; + tasks: ReadonlyMap; + flash?: { turnId: string; stepId?: string | undefined } | null | undefined; +}) { + const message = entry.message; + switch (message.type) { + case 'turn': + return null; + case 'step': + return ; + case 'user': + return ; + case 'assistant': + return ; + case 'thinking': + return ; + case 'tool_call': + return ; + case 'system': + return ; } +} + +function StepRow({ step, flashed }: { step: StepMessage; flashed: boolean }) { return ( -
- {prompt} +
+ {step.step_id} + + {step.status} + + {step.retry !== undefined ? ( + + retry {step.retry.failed_attempt}→{step.retry.next_attempt}/{step.retry.max_attempts}:{' '} + {step.retry.error_name} + + ) : null} + {step.finish_reason !== undefined ? finish: {step.finish_reason} : null} + {step.usage !== undefined ? ( + + in {step.usage.input_other + step.usage.input_cache_read + step.usage.input_cache_creation}{' '} + / out {step.usage.output} + + ) : null} + {step.end_reason !== undefined ? {step.end_reason} : null} + {step.end_message !== undefined ? {step.end_message} : null}
); } -function MarkerView({ marker }: { marker: TranscriptMarker }) { +// ---------------------------------------------------------------- messages + +function UserMessageView({ message }: { message: UserMessage }) { + const isUserInput = message.origin === undefined || message.origin.kind === 'user'; return ( -
-
-
- {marker.marker} - {marker.at !== undefined ? {relTime(Date.parse(marker.at))} : null} -
+
+
+ {message.message_id} + {message.origin !== undefined && message.origin.kind !== 'user' ? ( + {userOriginLabel(message.origin)} + ) : null} + {message.status === 'unread' ? queued : null}
- {marker.payload !== undefined ? : null} + {isUserInput ? ( +
+
+ +
+
+ ) : ( +
+ +
+ )} + {message.attachment_ids !== undefined && message.attachment_ids.length > 0 ? ( + + ) : null} + {message.skill_activations !== undefined && message.skill_activations.length > 0 ? ( +
+ {message.skill_activations.map((skill) => ( + + skill: {skill.skill_name} + + ))} +
+ ) : null}
); } -function TaskRefView({ - item, - task, -}: { - item: TranscriptTaskRef; - task: TranscriptTask | undefined; -}) { - const failed = - task !== undefined && - (task.state === 'failed' || task.state === 'timed_out' || task.state === 'lost'); +function userOriginLabel(origin: Exclude): string { + switch (origin.kind) { + case 'user': + return 'user'; + case 'cron': + return `cron ${origin.cron_id ?? ''}`.trim(); + case 'task': + return `task: ${origin.title}`; + case 'skill': + return `skill: ${origin.skill_name}`; + } +} + +function UserContentParts({ parts }: { parts: readonly ContentPart[] }) { + const text = parts + .filter((part) => part.type === 'text' || part.type === 'think') + .map((part) => part.text) + .join('\n'); + const media = parts.filter( + (part) => part.type === 'image' || part.type === 'audio' || part.type === 'video', + ); return ( -
-
- - task{task !== undefined ? `: ${task.kind}` : ''} - - {task?.description ?? item.taskId} - {task !== undefined ? ( - - {task.state} - {task.detached ? ' (detached)' : ''} - - ) : null} -
- {task !== undefined && task.outputTail !== '' ? ( -
-          {task.outputTail}
-        
+ <> + {text} + {media.length > 0 ? ( + + {media.map((part, index) => ( + + {part.type}: {mediaPartLabel(part)} + + ))} + ) : null} -
+ ); } -// ---------------------------------------------------------------- frames +function mediaPartLabel(part: ContentPart): string { + const name = part.meta['name']; + if (typeof name === 'string' && name !== '') return name; + const id = part.meta['id']; + if (typeof id === 'string' && id !== '') return id; + return part.text; +} -function AttachmentChips({ - ids, - attachments, -}: { - ids: readonly string[]; - attachments: ReadonlyMap; -}) { +function AssistantMessageView({ message }: { message: AssistantMessage }) { return ( -
- {ids.map((id) => { - const attachment = attachments.get(id); - const label = attachment?.name ?? attachment?.mediaType ?? id; - return ( - - 📎{' '} - - - ); - })} +
+
+ {message.text} + {message.status === 'streaming' ? : null} +
); } -function AttachmentLink({ - attachment, - label, -}: { - attachment: TranscriptAttachment | undefined; - label: string; -}) { - const sessionId = useContext(SessionContext); - const { baseUrl, config } = useConnection(); - const [downloading, setDownloading] = useState(false); - const [error, setError] = useState(null); - const source = attachment?.source; - if (source === undefined) return label; - if (source.kind === 'url') { - return ( - - {label} - - ); - } - const download = async (): Promise => { - setDownloading(true); - setError(null); - try { - const blob = await fetchTranscriptAttachment({ - baseUrl, - token: config.token.trim() || undefined, - sessionId, - source, - }); - const href = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = href; - link.download = attachment?.name ?? source.fileId; - link.click(); - setTimeout(() => { - URL.revokeObjectURL(href); - }, 0); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } finally { - setDownloading(false); - } - }; +function ThinkingMessageView({ message }: { message: ThinkingMessage }) { return ( - +
+ {message.text} + {message.status === 'streaming' ? : null} +
); } -function FrameView({ - frame, - tasks, - interactions, - attachments, -}: { - frame: TranscriptFrame; - tasks: ReadonlyMap; - interactions: ReadonlyMap; - attachments: ReadonlyMap; -}) { - switch (frame.kind) { - case 'text': { - const chips = - frame.attachmentIds !== undefined && frame.attachmentIds.length > 0 ? ( - - ) : null; - const taskBadge = - frame.taskId !== undefined ? ( -
- - task: {frame.taskId} - {tasks.get(frame.taskId) !== undefined ? ` (${tasks.get(frame.taskId)!.state})` : ''} - -
- ) : null; - const bubble = - frame.role === 'user' ? ( -
-
- {frame.text} -
-
- ) : ( -
- {frame.text} -
- ); - return ( - <> - {taskBadge} - {chips} - {bubble} - - ); - } - case 'thinking': - return ( -
- {frame.text} -
- ); - case 'tool': - return ; - case 'notice': - return ; - } +function AttachmentChips({ ids }: { ids: readonly string[] }) { + return ( +
+ {ids.map((id) => ( + + 📎 {id} + + ))} +
+ ); } -function ToolFrameView({ - frame, - tasks, +// ---------------------------------------------------------------- tool calls + +function ToolCallView({ + call, interactions, + tasks, }: { - frame: ToolCallFrame; - tasks: ReadonlyMap; - interactions: ReadonlyMap; + call: ToolCallMessage; + interactions: ReadonlyMap; + tasks: ReadonlyMap; }) { - const task = frame.taskId !== undefined ? tasks.get(frame.taskId) : undefined; - // The interaction anchored at this call (via approvalId, or by scanning the - // entity's toolCallId for requests that predate the back-link). + const task = call.task_id !== undefined ? tasks.get(call.task_id) : undefined; const linked = [...interactions.values()].filter( (interaction) => - interaction.interactionId === frame.approvalId || interaction.toolCallId === frame.toolCallId, + interaction.interaction_id === call.approval_id || + interaction.tool_call_id === call.tool_call_id, ); return (
tool - {frame.name} - {frame.toolCallId} - {frame.view !== undefined && frame.view !== frame.name ? ( - view: {frame.view} + {call.name} + {call.tool_call_id} + {call.view !== undefined && call.view !== call.name ? ( + view: {call.view} ) : null} - {frame.agentRefs?.map((ref) => ( - - agent: {ref.agentId} + {call.agent_refs?.map((ref) => ( + + agent: {ref.agent_id} ))} - {task !== undefined ? task: {task.state} : null} - {frame.todoId !== undefined ? ( - todo: {frame.todoId} + {task !== undefined ? task: {task.status} : null} + {call.todo_id !== undefined ? ( + todo: {call.todo_id} ) : null}
- {frame.input !== undefined ? ( - typeof frame.input === 'string' ? ( + {call.input !== undefined ? ( + typeof call.input === 'string' ? (
-            {frame.input}
+            {call.input}
           
) : ( - + ) + ) : call.input_text !== undefined && call.input_text !== '' ? ( +
+          {call.input_text}
+        
) : null} - {frame.output !== undefined ? ( - typeof frame.output === 'string' ? ( + {call.output !== undefined ? ( + typeof call.output === 'string' ? (
-            {frame.output}
+            {call.output}
           
) : ( - + ) - ) : task !== undefined && task.outputTail !== '' ? ( + ) : task !== undefined && task.output_tail !== '' ? (
-          {task.outputTail}
+          {task.output_tail}
         
) : null} - {frame.error !== undefined && frame.error !== frame.output ? ( -
{frame.error}
+ {call.error !== undefined && call.error !== call.output ? ( +
{call.error}
+ ) : null} + {call.progress !== undefined ? ( +
+ progress ({call.progress.kind}):{' '} + {call.progress.text ?? (call.progress.percent !== undefined ? `${call.progress.percent}%` : call.progress.custom_kind ?? '')} +
) : null} {linked.map((interaction) => ( - + ))}
); } +// ---------------------------------------------------------------- interactions + function InteractionEntityView({ interaction, nested, }: { - interaction: TranscriptInteraction; + interaction: InteractionMessage; nested?: boolean; }) { const { baseUrl, config } = useConnection(); const sessionId = useContext(SessionContext); const [busy, setBusy] = useState(false); const [respondError, setRespondError] = useState(null); - /** Question answers in progress: question text → selected option labels. */ + /** Question answers in progress: question id → selected option labels. */ const [selections, setSelections] = useState>>({}); - /** Question free-text ("Other") input: question text → draft. */ + /** Question free-text ("Other") input: question id → draft. */ const [others, setOthers] = useState>>({}); - const pending = interaction.state === 'pending'; - const questionRequest = - interaction.interactionKind === 'question' - ? (interaction.request as QuestionRequest | undefined) - : undefined; + const pending = interaction.status === 'pending'; + const questionRequest = interaction.kind === 'question' ? interaction.request : undefined; const api = { baseUrl, token: config.token, sessionId }; const run = (fn: () => Promise): void => { @@ -1198,41 +1022,45 @@ function InteractionEntityView({ }; const decide = (decision: 'approved' | 'rejected'): void => { - run(() => decideApproval(api, interaction.interactionId, decision)); + run(() => decideApproval(api, interaction.interaction_id, decision)); }; - const toggleOption = (question: QuestionItem, label: string): void => { + const toggleOption = (question: InteractionQuestionItem, label: string): void => { setSelections((prev) => { - const current = prev[question.question] ?? []; + const current = prev[question.id] ?? []; const next = - question.multiSelect === true + question.multi_select === true ? current.includes(label) ? current.filter((item) => item !== label) : [...current, label] : current.includes(label) ? [] : [label]; - return { ...prev, [question.question]: next }; + return { ...prev, [question.id]: next }; }); }; const submitAnswers = (): void => { const answers: Record = {}; - for (const [index, question] of (questionRequest?.questions ?? []).entries()) { - const selected = selections[question.question] ?? []; + for (const question of questionRequest?.questions ?? []) { + const selected = selections[question.id] ?? []; const optionIds = selected.flatMap((label) => { - const optionIndex = question.options.findIndex((option) => option.label === label); - return optionIndex < 0 ? [] : [`opt_${index}_${optionIndex}`]; + const match = question.options.find((option) => option.label === label); + return match === undefined ? [] : [match.id]; }); - const other = (others[question.question] ?? '').trim(); + const other = (others[question.id] ?? '').trim(); if (other !== '' && optionIds.length > 0) { - answers[`q_${index}`] = { kind: 'multi_with_other', option_ids: optionIds, other_text: other }; + answers[question.id] = { + kind: 'multi_with_other', + option_ids: optionIds, + other_text: other, + }; } else if (other !== '') { - answers[`q_${index}`] = { kind: 'other', text: other }; - } else if (optionIds.length > 1 || (question.multiSelect === true && optionIds.length > 0)) { - answers[`q_${index}`] = { kind: 'multi', option_ids: optionIds }; + answers[question.id] = { kind: 'other', text: other }; + } else if (optionIds.length > 1 || (question.multi_select === true && optionIds.length > 0)) { + answers[question.id] = { kind: 'multi', option_ids: optionIds }; } else if (optionIds.length === 1) { - answers[`q_${index}`] = { kind: 'single', option_id: optionIds[0]! }; + answers[question.id] = { kind: 'single', option_id: optionIds[0]! }; } } // Mirror the TUI adapter: no answers at all dismisses the question. @@ -1240,11 +1068,11 @@ function InteractionEntityView({ dismiss(); return; } - run(() => answerQuestion(api, interaction.interactionId, answers, 'enter')); + run(() => answerQuestion(api, interaction.interaction_id, answers, 'enter')); }; const dismiss = (): void => { - run(() => dismissQuestion(api, interaction.interactionId)); + run(() => dismissQuestion(api, interaction.interaction_id)); }; return ( @@ -1254,13 +1082,16 @@ function InteractionEntityView({ }`} >
- {interaction.interactionKind} - {interaction.state} - tool: {interaction.toolCallId} + {interaction.kind} + {interaction.status} + tool: {interaction.tool_call_id}
- {interaction.request !== undefined ? : null} + {interaction.request !== undefined && questionRequest === undefined ? ( + + ) : null} + {questionRequest !== undefined && !pending ? : null} {interaction.response !== undefined ? : null} - {pending && interaction.interactionKind === 'approval' ? ( + {pending && interaction.kind === 'approval' ? (
decide('approved')} disabled={busy}> Approve @@ -1273,14 +1104,14 @@ function InteractionEntityView({ {pending && questionRequest !== undefined ? (
{questionRequest.questions.map((question) => ( -
+
{question.header ?? question.question}
{question.options.map((option) => { - const selected = (selections[question.question] ?? []).includes(option.label); + const selected = (selections[question.id] ?? []).includes(option.label); return (
@@ -1325,20 +1156,112 @@ function InteractionEntityView({ ); } -function NoticeFrameView({ frame }: { frame: NoticeFrame }) { - const tone = - frame.level === 'error' - ? 'bg-red-950/50 text-red-400' - : frame.level === 'warning' - ? 'bg-amber-950/40 text-amber-300' - : 'bg-neutral-900/60 text-neutral-400'; +// ---------------------------------------------------------------- state entities + +function SystemMarkerView({ message }: { message: SystemMessage }) { return ( -
- {frame.source !== undefined ? ( - [{frame.source}] +
+
+
+ system({message.subtype}) + {message.system_id} + {message.at !== undefined ? {relTime(Date.parse(message.at))} : null} +
+
+ {message.payload !== undefined ? : null} +
+ ); +} + +function TaskCard({ task }: { task: TaskMessage }) { + const failed = + task.status === 'failed' || task.status === 'timed_out' || task.status === 'lost'; + return ( +
+
+ + task: {task.kind} + + {task.description ?? task.task_id} + + {task.status} + {task.detached ? ' (detached)' : ''} + + {task.child_agent_id !== undefined ? ( + agent: {task.child_agent_id} + ) : null} +
+ {task.output_tail !== '' ? ( +
+          {task.output_tail}
+        
+ ) : null} + {task.error !== undefined ? ( +
+          {task.error}
+        
+ ) : null} + {task.result_summary !== undefined ? ( +
{task.result_summary}
) : null} - {frame.message} - {frame.detail !== undefined ? : null}
); } + +function TodoCard({ todo }: { todo: TodoMessage }) { + return ( +
+
todo (latest)
+ {todo.items.map((entry, i) => ( +
+ + {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} + + + {entry.title} + +
+ ))} +
+ ); +} + +function SessionStateBadges({ sessionState }: { sessionState: SessionStateMessage }) { + return ( + <> + {sessionState.pending_interaction !== undefined && + sessionState.pending_interaction !== 'none' ? ( + {sessionState.pending_interaction} + ) : null} + {sessionState.model !== undefined ? {sessionState.model} : null} + {sessionState.permission !== undefined ? ( + perm: {sessionState.permission} + ) : null} + {sessionState.modes?.plan !== undefined ? plan mode : null} + {sessionState.modes?.swarm !== undefined ? swarm : null} + {sessionState.goal !== undefined ? ( + + goal: {sessionState.goal.status} + + ) : null} + {sessionState.context_tokens !== undefined ? ( + + ctx {sessionState.context_tokens} + {sessionState.max_context_tokens !== undefined + ? `/${sessionState.max_context_tokens}` + : ''} + + ) : null} + + ); +} diff --git a/apps/kimi-inspect/src/components/Inspector.tsx b/apps/kimi-inspect/src/components/Inspector.tsx index 1f9ccd1ee6c..e8cbf72683b 100644 --- a/apps/kimi-inspect/src/components/Inspector.tsx +++ b/apps/kimi-inspect/src/components/Inspector.tsx @@ -21,7 +21,8 @@ import { serviceByName } from '../channel'; import { useConnection } from '../connection'; import { type AnyService } from '../panels'; import { fetchAgentRuntimeBinding } from '../snapshots/api'; -import { fetchTranscriptPlan, type TranscriptPlanInfo } from '../transcript/api'; +import { fetchFullHistory } from '../transcript/api'; +import { projectPlans, type PlanInfo } from '../transcript/plan'; import { ActionButton, Badge, ErrorLine } from '../ui'; import { ScopePanels } from './ServicePanels'; @@ -175,20 +176,21 @@ export function Inspector({ } // --------------------------------------------------------------------------- -// Plan lookup — `GET /api/v1/sessions/{id}/transcript/plan`: the reviewed plan -// of one ExitPlanMode tool call, queried by tool_call_id (copy it from a tool -// frame in the chat view). Read-only, fetched on demand like everything else -// here. +// Plan lookup — derived from the message stream (`GET /sessions/{id}/history` +// full read + client-side `projectPlans`): the reviewed plan of one +// ExitPlanMode tool call, found by tool_call_id (copy it from a tool frame in +// the chat view), or every plan of the agent. Read-only, fetched on demand +// like everything else here. // --------------------------------------------------------------------------- function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string }) { const { baseUrl, config } = useConnection(); const [toolCallId, setToolCallId] = useState(''); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - // A plan belongs to one agent's transcript — stale results from another + // A plan belongs to one agent's timeline — stale results from another // session/agent are misleading, so reset on switch. useEffect(() => { setResult(null); @@ -200,16 +202,14 @@ function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string } try { setError(null); const token = config.token.trim(); + const messages = await fetchFullHistory({ + baseUrl, + token: token === '' ? undefined : token, + sessionId, + agentId, + }); const id = toolCallId.trim(); - setResult( - await fetchTranscriptPlan({ - baseUrl, - token: token === '' ? undefined : token, - sessionId, - agentId, - toolCallId: id === '' ? undefined : id, - }), - ); + setResult(projectPlans(messages, id === '' ? undefined : id)); } catch (error) { setResult(null); setError(error); @@ -256,7 +256,7 @@ function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string } ); } -function PlanEntryView({ entry }: { entry: TranscriptPlanInfo }) { +function PlanEntryView({ entry }: { entry: PlanInfo }) { const review = entry.review; return (
diff --git a/apps/kimi-inspect/src/components/ModelCatalogView.tsx b/apps/kimi-inspect/src/components/ModelCatalogView.tsx index 3b395e0db25..c7d9f95fcce 100644 --- a/apps/kimi-inspect/src/components/ModelCatalogView.tsx +++ b/apps/kimi-inspect/src/components/ModelCatalogView.tsx @@ -1,26 +1,20 @@ /** - * Model Catalog view — a three-column inspector: + * Model Catalog view — providers with their models and the default marker: * * left: every configured model (provider-grouped), its highlight synced * both ways with the center column — scrolling the center moves the * highlight (scrollspy), clicking an entry jumps the center to that * model; - * center: one section per model with its god object as a selectable JSON - * tree (provider / model layers + the resolved runtime view); - * right: the selected value and its provenance (source kind + detail) for - * the ACTIVE model. The selected path sticks across models, so the - * same field can be compared while scrolling. + * center: one section per model with ping and session creation actions. * * All data goes through the channel layer — `IModelCatalog` + - * `IModelService` for the list, `IModelCatalog.inspect` per model for the god - * objects — over the `/api/v1/debug` RPC surface. No bespoke REST calls. + * `IModelService` for the list — over the `/api/v1/debug` RPC surface. * There is no live event push; the queries refresh on a slow poll. */ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager'; import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage'; -import type { InspectionSource } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/inspection'; import { IModelCatalog, type ModelCatalogItem, @@ -32,40 +26,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { useConnection } from '../connection'; -import { ActionButton, Badge, ErrorLine, JsonTree, JsonView, errorMessage } from '../ui'; - -const SOURCE_TONES: Record< - InspectionSource['kind'], - 'sky' | 'amber' | 'violet' | 'green' | 'neutral' | 'red' -> = { - config: 'sky', - override: 'amber', - builtin: 'violet', - env: 'green', - synthesized: 'neutral', - none: 'red', -}; - -/** Row accent (left bar + text) of a tree node by its finally-effective source. */ -const KIND_ROW_CLASSES: Record = { - config: 'border-sky-500/70 text-sky-300', - override: 'border-amber-500/70 text-amber-300', - builtin: 'border-violet-500/70 text-violet-300', - env: 'border-emerald-500/70 text-emerald-300', - synthesized: 'border-neutral-600 text-neutral-500', - none: 'border-red-500/70 text-red-400', -}; - -const KIND_DOT_CLASSES: Record = { - config: 'bg-sky-400', - override: 'bg-amber-400', - builtin: 'bg-violet-400', - env: 'bg-emerald-400', - synthesized: 'bg-neutral-500', - none: 'bg-red-400', -}; - -const SOURCE_KINDS = ['config', 'override', 'builtin', 'env', 'synthesized', 'none'] as const; +import { ActionButton, Badge, ErrorLine, errorMessage } from '../ui'; interface FlatEntry { readonly item: ModelCatalogItem; @@ -125,7 +86,6 @@ export function ModelCatalogView({ // --- two-way sync between the left list and the center scroll ---------- const [activeId, setActiveId] = useState(null); - const [selectedPath, setSelectedPath] = useState('resolved'); const scrollRef = useRef(null); const listRef = useRef(null); const sectionRefs = useRef(new Map()); @@ -179,14 +139,8 @@ export function ModelCatalogView({ sectionRefs.current.get(modelId)?.scrollIntoView({ behavior: 'instant', block: 'start' }); }; - const selectIn = (modelId: string, path: string) => { - setActiveId(modelId); - setSelectedPath(path); - }; - const loading = providers.isLoading || models.isLoading || records.isLoading; const error = providers.error ?? models.error ?? records.error; - const activeItem = flatEntries.find((entry) => entry.item.model === activeId)?.item; return (
@@ -197,14 +151,6 @@ export function ModelCatalogView({ {providerList.length} providers · {items.length} models -
- {SOURCE_KINDS.map((kind) => ( - - - {kind} - - ))} -
queryClient.invalidateQueries({ queryKey: ['modelCatalog'] })}> Refresh @@ -229,7 +175,7 @@ export function ModelCatalogView({ >
- {/* center: one god object per model */} + {/* center: one section per model */}
{ if (el === null) sectionRefs.current.delete(entry.item.model); @@ -249,14 +193,6 @@ export function ModelCatalogView({ /> ))}
- {/* right: the selected value's provenance for the active model */} -
- {activeItem !== undefined ? ( - - ) : ( -
select a model
- )} -
); @@ -349,24 +285,15 @@ function LeftList({ function ModelSection({ entry, - selectedPath, - onSelect, onOpenSession, registerRef, }: { readonly entry: FlatEntry; - readonly selectedPath?: string; - readonly onSelect: (modelId: string, path: string) => void; readonly onOpenSession: (sessionId: string) => void; readonly registerRef: (el: HTMLElement | null) => void; }) { const { klient, baseUrl, config } = useConnection(); const { item, provider } = entry; - const inspection = useQuery({ - queryKey: ['modelCatalog', 'inspect', item.model], - queryFn: () => klient.core(IModelCatalog).inspect(item.model), - refetchInterval: 15_000, - }); const [ping, setPing] = useState< | { readonly status: 'idle' | 'running' } | { readonly status: 'done'; readonly result: ModelPingResult } @@ -419,21 +346,6 @@ function ModelSection({ } }; - const god = - inspection.data === undefined - ? undefined - : { - model: inspection.data.model, - provider: inspection.data.provider, - resolved: inspection.data.resolved, - }; - const sources = inspection.data?.sources; - const classForPath = (path: string): string | undefined => { - if (sources === undefined) return undefined; - const kind = findSource(sources, path).source?.kind; - return kind === undefined ? undefined : KIND_ROW_CLASSES[kind]; - }; - return (
@@ -481,20 +393,6 @@ function ModelSection({
) : null} {sessionError !== null ? : null} - {inspection.isLoading ? ( -
resolving inspection…
- ) : null} - {inspection.error !== null ? : null} - {god !== undefined ? ( - { - onSelect(item.model, path); - }} - rowClassName={classForPath} - /> - ) : null} ); } @@ -504,92 +402,6 @@ function usageLine(usage: TokenUsage): string { return `in ${input} · out ${usage.output}`; } -// --------------------------------------------------------------------------- -// right column -// --------------------------------------------------------------------------- - -function SourcePane({ modelId, path }: { readonly modelId: string; readonly path: string }) { - const { klient } = useConnection(); - const inspection = useQuery({ - queryKey: ['modelCatalog', 'inspect', modelId], - queryFn: () => klient.core(IModelCatalog).inspect(modelId), - refetchInterval: 15_000, - }); - if (inspection.isLoading) { - return
resolving inspection…
; - } - if (inspection.error !== null) return ; - if (inspection.data === undefined) return null; - - const data = inspection.data; - const god = { - model: data.model, - provider: data.provider, - resolved: data.resolved, - }; - const value = getPath(god, path); - const { source, inheritedFrom } = findSource(data.sources, path); - - return ( -
-
- {modelId} -
-
- {path} -
-
- {source !== undefined ? ( - {source.kind} - ) : ( - no source - )} - {inheritedFrom !== undefined ? ( - inherited from {inheritedFrom} - ) : null} -
- {source?.detail !== undefined ? ( -
- {source.detail} -
- ) : null} -
- value -
- -
- ); -} - -// --------------------------------------------------------------------------- -// helpers -// --------------------------------------------------------------------------- - -function getPath(root: unknown, path: string): unknown { - let current = root; - for (const segment of path.split('.')) { - if (current === null || current === undefined || typeof current !== 'object') return undefined; - current = (current as Record)[segment]; - } - return current; -} - -function findSource( - sources: Readonly>, - path: string, -): { readonly source?: InspectionSource; readonly inheritedFrom?: string } { - let current = path; - while (current !== '') { - const hit = sources[current]; - if (hit !== undefined) { - return { source: hit, inheritedFrom: current === path ? undefined : current }; - } - const index = current.lastIndexOf('.'); - current = index === -1 ? '' : current.slice(0, index); - } - return {}; -} - function formatContextSize(size: number): string { if (size <= 0) return '—'; if (size >= 1_000_000) { diff --git a/apps/kimi-inspect/src/components/audit/AuditPanel.tsx b/apps/kimi-inspect/src/components/audit/AuditPanel.tsx index 7dbdea72745..318e443bebf 100644 --- a/apps/kimi-inspect/src/components/audit/AuditPanel.tsx +++ b/apps/kimi-inspect/src/components/audit/AuditPanel.tsx @@ -1,32 +1,32 @@ /** * Audit panel — the `Audit` tab of the chat view's right dock - * (`RightPanel`): replays how the visible `TranscriptChatStore` was built, - * entry by entry. It used to be a standalone column docked inside the chat - * view. + * (`RightPanel`): replays how the visible `ChatStore` was built, entry by + * entry. It used to be a standalone column docked inside the chat view. * - * - Timeline (draggable slider + entry list): every REST page load, WS - * frame (`transcript.ops` / `transcript.reset`), loss signal, and user - * action the channel processed, with its timestamp. + * - Timeline (draggable slider + entry list): every REST history page, + * every WS message (entity / delta / state), and every channel event + * (subscribe ack, reconnect, catch-up fallback, prompt/cancel), with its + * timestamp. * - Detail tabs for the selected entry: `Diff` (structural diff vs the * previous entry — added/modified/removed colored), `State` (the full - * store state at that point, goal/plan/todos included), `Event` (the - * raw REST request/response or WS payload). + * store state at that point: entity timeline plus the interaction / + * task / todo / session.state entities), `Event` (the raw REST + * request/response or WS payload). */ -import { EMPTY_AGENT_STATE } from '@moonshot-ai/transcript'; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { diffValue, type DiffNode } from '../../audit/diff'; import { serializeState } from '../../audit/serialize'; import type { AuditEntry, AuditTrail } from '../../audit/trail'; import { tailTrunc } from '../../audit/truncate'; +import { EMPTY_CHAT_STATE } from '../../transcript/store'; import { Badge } from '../../ui'; import { plainNode, StateTree } from './StateTree'; -const KIND_TONE: Record = { +const KIND_TONE: Record = { rest: 'sky', - ops: 'green', - reset: 'violet', + ws: 'green', event: 'neutral', }; @@ -41,15 +41,14 @@ function EventJson({ entry }: { entry: AuditEntry }) { const payload = useMemo(() => { switch (entry.kind) { case 'rest': - return { request: entry.request, appliedAs: entry.appliedAs, response: entry.page }; - case 'ops': - return { envelopeAt: entry.envelopeAt, delivery: entry.delivery, ops: entry.ops }; - case 'reset': return { - envelopeAt: entry.envelopeAt, - hasMoreOlder: entry.hasMoreOlder, - snapshot: entry.snapshot, + request: entry.request, + mode: entry.mode, + messageCount: entry.messageCount, + inFlight: entry.inFlight, }; + case 'ws': + return entry.message; case 'event': return { event: entry.event, detail: entry.detail }; } @@ -89,7 +88,7 @@ export function AuditPanel({ trail }: { trail: AuditTrail }) { if (current === undefined || tab === 'event') return null; if (tab === 'state') return plainNode(serializeState(current.state)); const prevState = - currentPos > 0 ? (entries[currentPos - 1]?.state ?? EMPTY_AGENT_STATE) : EMPTY_AGENT_STATE; + currentPos > 0 ? (entries[currentPos - 1]?.state ?? EMPTY_CHAT_STATE) : EMPTY_CHAT_STATE; return diffValue(serializeState(prevState), serializeState(current.state)); }, [current, currentPos, entries, tab]); @@ -130,7 +129,7 @@ export function AuditPanel({ trail }: { trail: AuditTrail }) {
{entries.length === 0 ? (
- Nothing recorded yet — the initial transcript load is still running. + Nothing recorded yet — the initial history load is still running.
) : null} {entries.map((entry, pos) => ( diff --git a/apps/kimi-inspect/src/components/audit/StateTree.test.tsx b/apps/kimi-inspect/src/components/audit/StateTree.test.tsx index 8ce34226a26..70766c63908 100644 --- a/apps/kimi-inspect/src/components/audit/StateTree.test.tsx +++ b/apps/kimi-inspect/src/components/audit/StateTree.test.tsx @@ -7,41 +7,92 @@ * 2. Whole-subtree adds expand into fully fielded, indented tree rows. */ -import { EMPTY_AGENT_STATE, type AgentState, type TranscriptTurn } from '@moonshot-ai/transcript'; +import type { AssistantMessage, StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { diffValue } from '../../audit/diff'; import { serializeState } from '../../audit/serialize'; +import { EMPTY_CHAT_STATE, type ChatState } from '../../transcript/store'; import { plainNode, StateTree } from './StateTree'; -function turn(n: number, prompt: string): TranscriptTurn { +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +let tick = 0; + +function ts(): number { + tick += 1; + return T0 + tick * 1000; +} + +function turnMsg(n: number, text?: string): TurnMessage { return { - kind: 'turn', - turnId: `t${n}`, + type: 'turn', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + turn_id: `t${n}`, ordinal: n, - state: 'completed', + status: 'completed', origin: { kind: 'user' }, - prompt, - steps: [], + user_message_id: text, + }; +} + +function stepMsg(stepId: string): StepMessage { + return { + type: 'step', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + step_id: stepId, + turn_id: stepId.split('.')[0] ?? 't0', + ordinal: Number(stepId.split('.')[1] ?? '1'), + status: 'running', + }; +} + +function assistantMsg(stepId: string, text: string): AssistantMessage { + return { + type: 'assistant', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + message_id: `${stepId}.a0`, + turn_id: stepId.split('.')[0] ?? 't0', + step_id: stepId, + status: 'streaming', + text, }; } -function stateWith(items: readonly TranscriptTurn[]): AgentState { - return { ...EMPTY_AGENT_STATE, items }; +type FlatMessage = TurnMessage | StepMessage | AssistantMessage; + +function stateWithTimeline(items: readonly FlatMessage[]): ChatState { + return { + ...EMPTY_CHAT_STATE, + entries: items.map((message) => ({ + key: + message.type === 'turn' + ? `turn:${message.turn_id}` + : message.type === 'step' + ? `step:${message.step_id}` + : `assistant:${message.message_id}`, + message, + })), + }; } describe('StateTree', () => { it('collapses unchanged subtrees instead of dumping compact JSON', () => { - const t0 = turn(0, 'PROMPT_ZERO'); - const prev = stateWith([t0, turn(1, 'PROMPT_ONE')]); - const next: AgentState = { ...prev, items: [t0, turn(1, 'PROMPT_ONE_V2')] }; + const t0 = turnMsg(0, 'PROMPT_ZERO'); + const prev = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE')]); + const next: ChatState = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE_V2')]); const html = renderToStaticMarkup( , ); // No one-line JSON blob anywhere. - expect(html).not.toContain('{"kind"'); - // The unchanged turn t0 stays folded: its prompt is not rendered… + expect(html).not.toContain('{"type"'); + // The unchanged turn t0 stays folded: its marker is not rendered… expect(html).not.toContain('PROMPT_ZERO'); // …while the modified turn opens and shows old → new. expect(html).toContain('PROMPT_ONE_V2'); @@ -51,39 +102,31 @@ describe('StateTree', () => { it('expands whole-subtree adds into full field rows (all keys, no JSON dump)', () => { const root = diffValue( - serializeState(EMPTY_AGENT_STATE), - serializeState(stateWith([turn(0, 'HELLO')])), + serializeState(EMPTY_CHAT_STATE), + serializeState(stateWithTimeline([turnMsg(0, 'HELLO')])), ); const html = renderToStaticMarkup(); - expect(html).not.toContain('{"kind"'); - for (const field of ['turnId', 'ordinal', 'state', 'origin', 'prompt', 'steps']) { + expect(html).not.toContain('{"type"'); + for (const field of ['turn_id', 'ordinal', 'status', 'origin', 'timestamp', 'agent_id']) { expect(html).toContain(field); } expect(html).toContain('HELLO'); }); it('expands added subtrees with id-based keys and renders closing braces', () => { - const withSteps: TranscriptTurn = { - ...turn(0, 'Q'), - steps: [ - { - kind: 'step', - stepId: 't0.1', - turnId: 't0', - ordinal: 1, - state: 'running', - frames: [{ kind: 'thinking', frameId: 't0.1.f1', text: 'hmm' }], - }, - ], - }; const html = renderToStaticMarkup( , ); // Array children are keyed by their ids, not #indices. expect(html).toContain('t0.1'); - expect(html).toContain('t0.1.f1'); + expect(html).toContain('t0.1.a0'); expect(html).not.toContain('#0'); // Open containers end with an explicit closing brace row. expect(html).toContain(']'); @@ -92,12 +135,15 @@ describe('StateTree', () => { it('plain state mode opens to defaultDepth and shows all top-level fields', () => { const html = renderToStaticMarkup( - , + , ); - for (const field of ['items', 'tasks', 'interactions', 'todos', 'meta', 'hasMoreOlder']) { + for (const field of ['timeline', 'interactions', 'tasks', 'todos', 'hasMoreOlder']) { expect(html).toContain(field); } - expect(html).not.toContain('{"kind"'); + expect(html).not.toContain('{"type"'); }); it('collapses multiline strings into a hover-preview button', () => { diff --git a/apps/kimi-inspect/src/components/audit/StateTree.tsx b/apps/kimi-inspect/src/components/audit/StateTree.tsx index f85ca171895..66fd4f69ed1 100644 --- a/apps/kimi-inspect/src/components/audit/StateTree.tsx +++ b/apps/kimi-inspect/src/components/audit/StateTree.tsx @@ -1,7 +1,7 @@ /** * Diff-aware state tree for the audit panel. * - * Renders a serialized `AgentState` (see `audit/serialize.ts`) as a + * Renders a serialized `ChatState` (see `audit/serialize.ts`) as a * collapsible tree, colored by the structural diff against the previous * trail entry: added = green, removed = red + strikethrough, modified = * amber (`old → new` on leaves). Every field is rendered — long strings diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index 5c6e74b4b4b..12bd83e72e3 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -16,7 +16,7 @@ * every Service. */ -import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; +import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; import { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp'; import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; import { IAgentPermissionRulesService } from '@moonshot-ai/agent-core-v2/agent/permissionRules/permissionRules'; @@ -127,10 +127,10 @@ export const SESSION_PANELS: readonly ServicePanelDef[] = [ export const AGENT_PANELS: readonly ServicePanelDef[] = [ { - id: String(IAgentActivityView), - label: 'AgentActivityView', + id: String(IAgentLoopService), + label: 'AgentLoopService', scope: 'agent', - fetch: (svc) => call(svc, 'state'), + fetch: (svc) => call(svc, 'activitySnapshot'), }, { id: String(IAgentProfileService), diff --git a/apps/kimi-inspect/src/transcript/api.ts b/apps/kimi-inspect/src/transcript/api.ts index 7aa54cee855..907456c82e8 100644 --- a/apps/kimi-inspect/src/transcript/api.ts +++ b/apps/kimi-inspect/src/transcript/api.ts @@ -1,282 +1,127 @@ /** - * REST client for the transcript page endpoint: - * `GET {baseUrl}/api/v1/sessions/{sessionId}/transcript`. + * REST client for the history endpoint of the message protocol: + * `GET {baseUrl}/api/v1/sessions/{sessionId}/history`. * - * This is the ONLY source of full transcript state: the initial load fetches - * the newest page, a full refresh re-reads page by page from the tail - * backwards, and "load earlier" pages further with a `before_turn` cursor. - * (The WS channel, by contrast, carries incremental `transcript.ops` only.) + * This is the ONLY source of persisted (completed) timeline state: the + * initial load fetches the newest page, "load earlier" pages further with a + * `before_turn` cursor, and a reconnect catch-up pages forward from an + * `after_step` cursor. The in-flight step's entities arrive over the WS + * recovery payload instead (idempotent replace-by-id at the seam). * - * Pages are turn-segment slices keyed by a turn-id cursor (`before_turn` - * pages towards older turns). The response is validated with the - * package-owned `transcriptResponseSchema` — the schema is the single source - * of truth for the wire shape, local code consumes the domain model types. + * Pages are flat entity-message slices (`{ messages, in_flight? }`, + * time-ordered, same schemas as the WS stream). There is deliberately no + * has-more flag: a page shorter than `page_size` is the end in that + * direction, an empty page is definitive. */ -import { - transcriptOpsCatchupResponseSchema, - transcriptPlanResponseSchema, - transcriptResponseSchema, - type AttachmentSource, - type TranscriptAttachment, - type TranscriptInteraction, - type TranscriptItem, - type TranscriptMeta, - type TranscriptOperation, - type TranscriptTask, - type TranscriptTodo, -} from '@moonshot-ai/transcript'; +import { historyResponseSchema, type HistoryMessage } from '@moonshot-ai/kap-server/protocol'; -type StoredAttachmentSource = Extract; +export const HISTORY_PAGE_SIZE = 500; -export interface FetchTranscriptAttachmentOptions { - readonly baseUrl: string; - readonly token?: string; - readonly sessionId: string; - readonly source: StoredAttachmentSource; - readonly fetchImpl?: typeof fetch; -} - -export function transcriptAttachmentUrl( - baseUrl: string, - sessionId: string, - source: AttachmentSource, -): string { - if (source.kind === 'url') return source.url; - if (source.kind === 'file') { - return `${baseUrl}/api/v1/files/${encodeURIComponent(source.fileId)}`; - } - return `${baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/media/${encodeURIComponent(source.fileId)}`; -} - -export async function fetchTranscriptAttachment( - opts: FetchTranscriptAttachmentOptions, -): Promise { - const headers: Record = {}; - if (opts.token !== undefined && opts.token !== '') { - headers['authorization'] = `Bearer ${opts.token}`; - } - const doFetch = opts.fetchImpl ?? fetch; - const res = await doFetch( - transcriptAttachmentUrl(opts.baseUrl, opts.sessionId, opts.source), - { headers }, - ); - if (!res.ok) throw new Error(`attachment download failed (${res.status})`); - return res.blob(); -} - -/** One transcript page as merged by the chat store. */ -export interface TranscriptPage { - readonly items: readonly TranscriptItem[]; - /** `has_more` in the query direction — more older turns exist. */ - readonly hasMoreOlder: boolean; - /** Global, unpaginated state (every response carries the current whole). */ - readonly tasks: readonly TranscriptTask[]; - readonly interactions: readonly TranscriptInteraction[]; - readonly attachments: readonly TranscriptAttachment[]; - readonly todos: readonly TranscriptTodo[]; - readonly meta: TranscriptMeta; - readonly pendingInteractions: readonly string[]; - /** Op-batch watermark (state includes every batch with seq <= N); absent on legacy servers. */ - readonly seq?: number | undefined; +export interface HistoryPage { + readonly messages: readonly HistoryMessage[]; + /** Current streaming position of a live session; absent for idle/cold ones. */ + readonly inFlight?: { turn_id: string; step_id: string }; } -/** One turn per page: fine-grained paging — the viewport grows a turn at a time. */ -export const TRANSCRIPT_PAGE_SIZE = 1; - -export interface FetchTranscriptPageOptions { +export interface FetchHistoryPageOptions { readonly baseUrl: string; - readonly token?: string | undefined; + readonly token?: string; readonly sessionId: string; readonly agentId: string; - /** Turn-id cursor; when set, fetches up to `pageSize` segments strictly older. */ - readonly beforeTurn?: string | undefined; - readonly pageSize?: number | undefined; + /** Turn-id cursor; fetches up to `pageSize` messages strictly older than that turn. */ + readonly beforeTurn?: string; + /** Step-id cursor; fetches up to `pageSize` messages strictly newer than that step. */ + readonly afterStep?: string; + readonly pageSize?: number; /** Injectable for tests. */ readonly fetchImpl?: typeof fetch; } -export async function fetchTranscriptPage( - opts: FetchTranscriptPageOptions, -): Promise { +export async function fetchHistoryPage(opts: FetchHistoryPageOptions): Promise { const params = new URLSearchParams({ agent_id: opts.agentId, - page_size: String(opts.pageSize ?? TRANSCRIPT_PAGE_SIZE), + page_size: String(opts.pageSize ?? HISTORY_PAGE_SIZE), }); if (opts.beforeTurn !== undefined) params.set('before_turn', opts.beforeTurn); + if (opts.afterStep !== undefined) params.set('after_step', opts.afterStep); const headers: Record = {}; if (opts.token !== undefined && opts.token !== '') { headers['authorization'] = `Bearer ${opts.token}`; } const doFetch = opts.fetchImpl ?? fetch; const res = await doFetch( - `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/transcript?${params.toString()}`, + `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/history?${params.toString()}`, { headers }, ); const envelope = (await res.json()) as { code: number; msg: string; data: unknown }; if (envelope.code !== 0) { - throw new Error(`transcript page failed (${envelope.code}): ${envelope.msg}`); + throw new Error(`history page failed (${envelope.code}): ${envelope.msg}`); } - const parsed = transcriptResponseSchema.safeParse(envelope.data); + const parsed = historyResponseSchema.safeParse(envelope.data); if (!parsed.success) { - throw new Error('transcript page: unexpected response shape'); + throw new Error('history page: unexpected response shape'); } - const items: readonly TranscriptItem[] = parsed.data.items; - const tasks: readonly TranscriptTask[] = parsed.data.tasks; - const interactions: readonly TranscriptInteraction[] = parsed.data.interactions; - const attachments: readonly TranscriptAttachment[] = parsed.data.attachments; - const todos: readonly TranscriptTodo[] = parsed.data.todos; - return { - items, - hasMoreOlder: parsed.data.has_more, - tasks, - interactions, - attachments, - todos, - meta: parsed.data.meta, - pendingInteractions: parsed.data.pending_interactions, - seq: parsed.data.seq, - }; -} - -// ---------------------------------------------------------------- ops catch-up - -/** One sequenced op batch from the catch-up endpoint. */ -export interface TranscriptOpBatch { - readonly seq: number; - readonly ops: readonly TranscriptOperation[]; -} - -export interface TranscriptOpsCatchup { - readonly batches: readonly TranscriptOpBatch[]; - readonly latestSeq: number; - /** False = the journal cannot cover `sinceSeq`; the caller must full-refresh. */ - readonly complete: boolean; -} - -export interface FetchTranscriptOpsOptions { - readonly baseUrl: string; - readonly token?: string | undefined; - readonly sessionId: string; - readonly agentId: string; - /** Return journaled batches with seq strictly greater than this watermark. */ - readonly sinceSeq: number; - /** Injectable for tests. */ - readonly fetchImpl?: typeof fetch; + return { messages: parsed.data.messages, inFlight: parsed.data.in_flight }; } /** - * Point-to-point catch-up: `GET .../transcript/ops?agent_id=&since_seq=N`. - * Available on sequenced servers; a 404/envelope error means the server - * predates the endpoint and the caller should fall back to a full refresh. + * Read the agent's WHOLE history (newest page + `before_turn` paging to the + * beginning) in timeline order. On-demand debug reads only (plan lookup) — + * the chat channel pages lazily instead. */ -export async function fetchTranscriptOps( - opts: FetchTranscriptOpsOptions, -): Promise { - const params = new URLSearchParams({ - agent_id: opts.agentId, - since_seq: String(opts.sinceSeq), - }); - const headers: Record = {}; - if (opts.token !== undefined && opts.token !== '') { - headers['authorization'] = `Bearer ${opts.token}`; - } - const doFetch = opts.fetchImpl ?? fetch; - const res = await doFetch( - `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/transcript/ops?${params.toString()}`, - { headers }, - ); - const envelope = (await res.json()) as { code: number; msg: string; data: unknown }; - if (envelope.code !== 0) { - throw new Error(`transcript ops failed (${envelope.code}): ${envelope.msg}`); - } - const parsed = transcriptOpsCatchupResponseSchema.safeParse(envelope.data); - if (!parsed.success) { - throw new Error('transcript ops: unexpected response shape'); - } - return { - batches: parsed.data.batches, - latestSeq: parsed.data.latest_seq, - complete: parsed.data.complete, - }; -} - -// ------------------------------------------------------------------ plan lookup - -/** The review round-trip of one ExitPlanMode call, from the plan endpoint. */ -export interface TranscriptPlanReview { - readonly state: 'pending' | 'approved' | 'rejected' | 'cancelled'; - readonly selectedOption?: string | undefined; - readonly feedback?: string | undefined; -} - -/** Plan information of one ExitPlanMode tool call (`GET .../transcript/plan`). */ -export interface TranscriptPlanInfo { - readonly toolCallId: string; - readonly turnId: string; - /** Which fact the content was projected from server-side. */ - readonly source: 'interaction' | 'display' | 'output'; - readonly plan: string; - readonly path?: string | undefined; - readonly options?: readonly { label: string; description?: string | undefined }[] | undefined; - readonly review?: TranscriptPlanReview | undefined; -} - -export interface FetchTranscriptPlanOptions { +export async function fetchFullHistory(opts: { readonly baseUrl: string; - readonly token?: string | undefined; + readonly token?: string; readonly sessionId: string; readonly agentId: string; - /** Narrow the read to one ExitPlanMode call; omitted lists every plan of the agent. */ - readonly toolCallId?: string | undefined; - /** Injectable for tests. */ + readonly pageSize?: number; readonly fetchImpl?: typeof fetch; +}): Promise { + const pageSize = opts.pageSize ?? HISTORY_PAGE_SIZE; + const messages: HistoryMessage[] = []; + const seen = new Set(); + let beforeTurn: string | undefined; + for (;;) { + const page = await fetchHistoryPage({ ...opts, beforeTurn, pageSize }); + if (page.messages.length === 0) break; + const fresh: HistoryMessage[] = []; + for (const message of page.messages) { + const key = historyEntityKey(message); + if (seen.has(key)) continue; + seen.add(key); + fresh.push(message); + } + messages.unshift(...fresh); + if (page.messages.length < pageSize) break; + const oldest = page.messages + .map((message) => ('turn_id' in message ? message.turn_id : undefined)) + .find((turnId) => turnId !== undefined); + if (oldest === undefined || oldest === beforeTurn) break; + beforeTurn = oldest; + } + return messages; } -/** - * Plan lookup: `GET .../transcript/plan?agent_id=[&tool_call_id=]`, in - * timeline order. With `toolCallId` set, a 40416 envelope means the tool - * call does not exist or is not an ExitPlanMode call (the message says - * which). - */ -export async function fetchTranscriptPlan( - opts: FetchTranscriptPlanOptions, -): Promise { - const params = new URLSearchParams({ agent_id: opts.agentId }); - if (opts.toolCallId !== undefined && opts.toolCallId !== '') { - params.set('tool_call_id', opts.toolCallId); - } - const headers: Record = {}; - if (opts.token !== undefined && opts.token !== '') { - headers['authorization'] = `Bearer ${opts.token}`; - } - const doFetch = opts.fetchImpl ?? fetch; - const res = await doFetch( - `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/transcript/plan?${params.toString()}`, - { headers }, - ); - const envelope = (await res.json()) as { code: number; msg: string; data: unknown }; - if (envelope.code !== 0) { - throw new Error(`transcript plan failed (${envelope.code}): ${envelope.msg}`); - } - const parsed = transcriptPlanResponseSchema.safeParse(envelope.data); - if (!parsed.success) { - throw new Error('transcript plan: unexpected response shape'); +function historyEntityKey(message: HistoryMessage): string { + switch (message.type) { + case 'turn': + return `turn:${message.turn_id}`; + case 'step': + return `step:${message.step_id}`; + case 'user': + case 'assistant': + case 'thinking': + return `${message.type}:${message.message_id}`; + case 'tool_call': + return `tool_call:${message.tool_call_id}`; + case 'system': + return `system:${message.system_id}`; + case 'interaction': + return `interaction:${message.interaction_id}`; + case 'task': + return `task:${message.task_id}`; + case 'todo': + return `todo:${message.todo_id}`; } - return parsed.data.plans.map((entry) => ({ - toolCallId: entry.tool_call_id, - turnId: entry.turn_id, - source: entry.source, - plan: entry.plan, - path: entry.path, - options: entry.options, - review: - entry.review === undefined - ? undefined - : { - state: entry.review.state, - selectedOption: entry.review.selected_option, - feedback: entry.review.feedback, - }, - })); } diff --git a/apps/kimi-inspect/src/transcript/channel.ts b/apps/kimi-inspect/src/transcript/channel.ts new file mode 100644 index 00000000000..8e4b2f86142 --- /dev/null +++ b/apps/kimi-inspect/src/transcript/channel.ts @@ -0,0 +1,271 @@ +/** + * Chat channel — owns the `ChatStore`, the `AuditTrail`, the REST history + * pipeline and the `/api/v3/ws` subscription for one (session, agent) pair. + * + * Recovery per the protocol, all of it converging through idempotent + * replace-by-id upserts (no buffering, no cursors beyond the two REST + * page cursors, no reset frames): + * + * - Initial load / full refresh: newest REST history page (`replace`), + * then re-cover the previously loaded window with `before_turn` pages. + * - Live + recovery payload: every WS message is applied to the store + * as it lands; recovery and live are the same path. + * - Subscribe ack (initial and every reconnect): `after_step` catch-up + * anchored at the newest TERMINAL step (the server answers with the + * slice after that step's last entity, so the step that was streaming + * at disconnect is re-read in full; overlap is idempotent). An empty + * catch-up is verified against the newest page — if the anchor itself + * is gone (undo/clear while disconnected), fall back to a full refresh. + * - `in_flight` on a history response means the WS replay re-sends that + * step's entities from the start; nothing to do but let them land. + */ + +import type { WsLikeCtor } from '../channel/wsLike'; +import { AuditTrail } from '../audit/trail'; +import { fetchHistoryPage, HISTORY_PAGE_SIZE, type HistoryPage } from './api'; +import { + ChatStore, + newestTerminalStepId, + oldestTurnId, + recoverLoadedWindow, +} from './store'; +import { ChatWs } from './ws'; + +export interface ChatChannelOptions { + readonly baseUrl: string; + readonly token?: string; + readonly sessionId: string; + readonly agentId: string; + readonly pageSize?: number; + readonly WebSocketImpl?: WsLikeCtor; + readonly fetchImpl?: typeof fetch; + readonly reconnectDelayMs?: number; + readonly notifyIntervalMs?: number; + /** Fired before a replace-mode refresh drops the current window (scroll anchor hook). */ + readonly onWillReplace?: () => void; + readonly onLoaded?: () => void; + readonly onLoadError?: (error: unknown) => void; +} + +export class ChatChannel { + readonly store: ChatStore; + readonly trail: AuditTrail; + + private readonly opts: ChatChannelOptions; + private readonly pageSize: number; + private readonly ws: ChatWs; + private queue: Promise = Promise.resolve(); + private refreshQueued = false; + private catchUpQueued = false; + private disposed = false; + + constructor(opts: ChatChannelOptions) { + this.opts = opts; + this.pageSize = opts.pageSize ?? HISTORY_PAGE_SIZE; + this.store = new ChatStore({ notifyIntervalMs: opts.notifyIntervalMs }); + this.trail = new AuditTrail(); + this.ws = new ChatWs({ + url: opts.baseUrl, + token: opts.token, + sessionId: opts.sessionId, + agentIds: [opts.agentId], + WebSocketImpl: opts.WebSocketImpl, + reconnectDelayMs: opts.reconnectDelayMs, + handlers: { + onMessage: (message) => { + this.store.applyLive(message); + this.trail.recordWs(message, this.store.getState()); + }, + onAck: (code, msg) => { + if (code === 0) { + this.trail.recordEvent('ack', undefined, this.store.getState()); + this.scheduleCatchUp(); + return; + } + this.trail.recordEvent('ack-error', msg, this.store.getState()); + this.opts.onLoadError?.(new Error(`subscribe rejected (${code}): ${msg ?? ''}`)); + }, + onProtocolError: (code, msg) => { + this.trail.recordEvent('protocol-error', `${code}: ${msg}`, this.store.getState()); + }, + onInvalidFrame: () => { + this.trail.recordEvent('invalid-frame', undefined, this.store.getState()); + }, + onReconnectScheduled: () => { + this.trail.recordEvent('reconnect', undefined, this.store.getState()); + }, + }, + }); + } + + /** Kick the initial load (the socket is already connecting). */ + start(): void { + this.scheduleRefresh(); + } + + /** Page one older slice into the window (`before_turn`); rejects on fetch failure. */ + async loadOlder(): Promise { + const oldest = oldestTurnId(this.store.getState().entries); + if (oldest === undefined) return; + const page = await this.fetchPage({ beforeTurn: oldest }); + if (this.disposed) return; + this.store.applyHistoryPage(page.messages, 'prepend'); + this.store.setHasMoreOlder(page.messages.length === this.pageSize); + this.trail.recordRest( + { beforeTurn: oldest, pageSize: this.pageSize }, + 'prepend', + page.messages.length, + page.inFlight, + this.store.getState(), + ); + } + + /** Force a WS reconnect (debug/testing): the ack re-triggers the after_step catch-up. */ + reconnect(delayMs = 0): void { + this.ws.reconnect(delayMs); + } + + close(): void { + this.disposed = true; + this.ws.close(); + this.store.flushNotify(); + } + + private scheduleRefresh(): void { + if (this.refreshQueued) return; + this.refreshQueued = true; + this.enqueue(async () => { + this.refreshQueued = false; + await this.doRefresh(); + }); + } + + private scheduleCatchUp(): void { + if (this.catchUpQueued) return; + this.catchUpQueued = true; + this.enqueue(async () => { + this.catchUpQueued = false; + await this.doCatchUp(); + }); + } + + private enqueue(task: () => Promise): void { + this.queue = this.queue.then(task).catch(() => {}); + } + + private async doRefresh(): Promise { + const prevOldest = oldestTurnId(this.store.getState().entries); + if (prevOldest !== undefined) this.opts.onWillReplace?.(); + try { + const page = await this.fetchPage({}); + if (this.disposed) return; + this.store.applyHistoryPage(page.messages, 'replace'); + this.store.setHasMoreOlder(page.messages.length === this.pageSize); + this.trail.recordRest( + { pageSize: this.pageSize }, + 'replace', + page.messages.length, + page.inFlight, + this.store.getState(), + ); + await recoverLoadedWindow( + this.store, + prevOldest, + async (beforeTurn) => { + const older = await this.fetchPage({ beforeTurn }); + if (this.disposed) return []; + this.store.setHasMoreOlder(older.messages.length === this.pageSize); + return older.messages; + }, + () => this.disposed, + (beforeTurn, messages) => { + this.trail.recordRest( + { beforeTurn, pageSize: this.pageSize }, + 'prepend', + messages.length, + undefined, + this.store.getState(), + ); + }, + ); + if (!this.disposed) this.opts.onLoaded?.(); + } catch (error) { + if (!this.disposed) this.opts.onLoadError?.(error); + } + } + + private async doCatchUp(): Promise { + const anchor = newestTerminalStepId(this.store.getState().entries); + if (anchor === undefined) { + this.scheduleRefresh(); + return; + } + let cursor = anchor; + for (;;) { + let page: HistoryPage; + try { + page = await this.fetchPage({ afterStep: cursor }); + } catch (error) { + if (!this.disposed) this.opts.onLoadError?.(error); + return; + } + if (this.disposed) return; + if (page.messages.length === 0) { + let probe: HistoryPage; + try { + probe = await this.fetchPage({}); + } catch { + return; + } + if (this.disposed) return; + if (!anchorAliveInPage(probe.messages, cursor)) { + this.trail.recordEvent( + 'catchup-refresh', + `anchor ${cursor} no longer exists`, + this.store.getState(), + ); + this.scheduleRefresh(); + } + return; + } + this.store.applyHistoryPage(page.messages, 'tail'); + this.trail.recordRest( + { afterStep: cursor, pageSize: this.pageSize }, + 'tail', + page.messages.length, + page.inFlight, + this.store.getState(), + ); + if (page.messages.length < this.pageSize) return; + const next = newestTerminalStepId(this.store.getState().entries); + if (next === undefined || next === cursor) return; + cursor = next; + } + } + + private fetchPage(cursor: { + beforeTurn?: string; + afterStep?: string; + pageSize?: number; + }): Promise { + return fetchHistoryPage({ + baseUrl: this.opts.baseUrl, + token: this.opts.token, + sessionId: this.opts.sessionId, + agentId: this.opts.agentId, + beforeTurn: cursor.beforeTurn, + afterStep: cursor.afterStep, + pageSize: cursor.pageSize ?? this.pageSize, + fetchImpl: this.opts.fetchImpl, + }); + } +} + +function anchorAliveInPage(messages: HistoryPage['messages'], cursor: string): boolean { + const cursorTurn = cursor.split('.')[0]!; + return messages.some( + (message) => + ('step_id' in message && message.step_id === cursor) || + ('turn_id' in message && message.turn_id === cursorTurn), + ); +} diff --git a/apps/kimi-inspect/src/transcript/plan.ts b/apps/kimi-inspect/src/transcript/plan.ts new file mode 100644 index 00000000000..ab2144e9a31 --- /dev/null +++ b/apps/kimi-inspect/src/transcript/plan.ts @@ -0,0 +1,189 @@ +/** + * Plan derivation from the message stream — the new-protocol replacement + * for the removed `GET /transcript/plan` endpoint. + * + * Under the message protocol there is no plan lookup endpoint; the data + * lives in the timeline itself: the EnterPlanMode/ExitPlanMode tool calls, + * the approval interaction that carries the review (its + * `request.tool_input_display` holds the `plan_review` display payload with + * the plan content, path and offered options; its `response` holds the + * decision, selected label and feedback), and the `system(plan.revision)` + * version marker (its payload path points at the plan document). + * `session.state.modes.plan` mirrors the current mode/revision over the WS + * but is not part of REST history, so derivation here runs purely over a + * history message list (in timeline order). + */ + +import type { + HistoryMessage, + InteractionMessage, + ToolCallMessage, +} from '@moonshot-ai/kap-server/protocol'; + +export interface PlanReview { + readonly state: 'pending' | 'approved' | 'rejected' | 'cancelled'; + readonly selectedOption?: string; + readonly feedback?: string; +} + +export interface PlanInfo { + readonly toolCallId: string; + readonly turnId: string; + /** Which message the content was derived from. */ + readonly source: 'interaction' | 'display' | 'output'; + readonly plan: string; + readonly path?: string; + readonly options?: readonly { label: string; description?: string }[]; + readonly review?: PlanReview; +} + +export function projectPlans( + messages: readonly HistoryMessage[], + toolCallId?: string, +): PlanInfo[] { + const interactions: InteractionMessage[] = []; + const revisionPaths: string[] = []; + for (const message of messages) { + if (message.type === 'interaction') interactions.push(message); + if (message.type === 'system' && message.subtype === 'plan.revision') { + const path = readRevisionPath(message.payload); + if (path !== undefined) revisionPaths.push(path); + } + } + const plans: PlanInfo[] = []; + for (const message of messages) { + if (message.type !== 'tool_call' || message.name !== 'ExitPlanMode') continue; + if (toolCallId !== undefined && message.tool_call_id !== toolCallId) continue; + const info = projectPlanCall(message, interactions); + if (info === undefined) continue; + plans.push( + info.path === undefined && revisionPaths.length > 0 + ? { ...info, path: revisionPaths.at(-1) } + : info, + ); + } + return plans; +} + +function projectPlanCall( + call: ToolCallMessage, + interactions: readonly InteractionMessage[], +): PlanInfo | undefined { + const interaction = interactions.find( + (candidate) => + candidate.kind === 'approval' && + (candidate.interaction_id === call.approval_id || + (call.approval_id === undefined && candidate.tool_call_id === call.tool_call_id)), + ); + const review = readPlanReview(interaction); + if (interaction !== undefined && interaction.kind === 'approval') { + const fromInteraction = readPlanReviewDisplay(interaction.request?.tool_input_display); + if (fromInteraction !== undefined) { + return { + toolCallId: call.tool_call_id, + turnId: call.turn_id, + source: 'interaction', + ...fromInteraction, + review, + }; + } + } + const fromDisplay = readPlanReviewDisplay(call.display); + if (fromDisplay !== undefined) { + return { + toolCallId: call.tool_call_id, + turnId: call.turn_id, + source: 'display', + ...fromDisplay, + review, + }; + } + const fromOutput = parsePlanFromOutput(call.output); + if (fromOutput !== undefined) { + return { + toolCallId: call.tool_call_id, + turnId: call.turn_id, + source: 'output', + ...fromOutput, + review, + }; + } + return undefined; +} + +function readPlanReview(interaction: InteractionMessage | undefined): PlanReview | undefined { + if (interaction === undefined || interaction.kind !== 'approval') return undefined; + const state = interaction.status; + if (state !== 'pending' && state !== 'approved' && state !== 'rejected' && state !== 'cancelled') { + return undefined; + } + const response = interaction.response; + const selected = + typeof response?.selected_label === 'string' && response.selected_label.length > 0 + ? response.selected_label + : undefined; + const feedback = + typeof response?.feedback === 'string' && response.feedback.length > 0 + ? response.feedback + : undefined; + return { state, selectedOption: selected, feedback }; +} + +interface PlanReviewDisplayInfo { + readonly plan: string; + readonly path?: string; + readonly options?: readonly { label: string; description?: string }[]; +} + +function readPlanReviewDisplay(display: unknown): PlanReviewDisplayInfo | undefined { + if (display === null || typeof display !== 'object') return undefined; + const d = display as { kind?: unknown; plan?: unknown; path?: unknown; options?: unknown }; + if (d.kind !== 'plan_review' || typeof d.plan !== 'string' || d.plan.trim().length === 0) { + return undefined; + } + const options = Array.isArray(d.options) + ? d.options + .map((option: unknown): { label: string; description?: string } | null => { + if (option === null || typeof option !== 'object') return null; + const o = option as { label?: unknown; description?: unknown }; + if (typeof o.label !== 'string' || o.label.length === 0) return null; + return { + label: o.label, + description: typeof o.description === 'string' ? o.description : undefined, + }; + }) + .filter((o): o is { label: string; description?: string } => o !== null) + : undefined; + return { + plan: d.plan, + path: typeof d.path === 'string' ? d.path : undefined, + options: options !== undefined && options.length > 0 ? options : undefined, + }; +} + +function readRevisionPath(payload: unknown): string | undefined { + if (payload === null || typeof payload !== 'object') return undefined; + const path = (payload as { path?: unknown }).path; + return typeof path === 'string' && path.length > 0 ? path : undefined; +} + +const PLAN_SAVED_TO_MARKER = 'Plan saved to: '; +const PLAN_BODY_MARKERS = ['## Approved Plan:\n', '## Plan (auto-approved, not user-reviewed):\n']; + +function parsePlanFromOutput(output: unknown): { plan: string; path?: string } | undefined { + if (typeof output !== 'string') return undefined; + let path: string | undefined; + for (const line of output.split('\n')) { + if (line.startsWith(PLAN_SAVED_TO_MARKER)) { + path = line.slice(PLAN_SAVED_TO_MARKER.length).trim() || undefined; + break; + } + } + for (const marker of PLAN_BODY_MARKERS) { + const index = output.indexOf(marker); + if (index === -1) continue; + const plan = output.slice(index + marker.length); + if (plan.trim().length > 0) return { plan, path }; + } + return undefined; +} diff --git a/apps/kimi-inspect/src/transcript/store.ts b/apps/kimi-inspect/src/transcript/store.ts index 6b9ed94fc49..f5ad59ca601 100644 --- a/apps/kimi-inspect/src/transcript/store.ts +++ b/apps/kimi-inspect/src/transcript/store.ts @@ -1,115 +1,185 @@ /** - * Per-(session, agent) transcript state for the chat view. + * Per-(session, agent) chat state for the message protocol v3. * - * A thin observable wrapper over the package's L1 convergence path - * (`applyOperation` on an `AgentState`) — the reducer is NOT re-implemented - * here. State arrives through exactly two channels: + * The store is a deliberately thin reflection of the wire: every entity + * message upserts by (type, own id) with its content fields as the + * authoritative whole (replace-by-id), the delta family + * (`assistant.delta` / `thinking.delta` / `tool_call.delta`) appends to the + * already-existing entity (an entity always precedes its deltas on the + * stream; an orphan delta is dropped — the entity's next upsert carries the + * cumulative content anyway), and `tool.progress` patches the entity's + * latest-progress field. Recovery payloads and live traffic are applied + * through the exact same path — idempotent overwrite makes them + * indistinguishable, so there is no reset/buffer/cursor machinery at all. * - * - REST pages (`applyPage`): the only source of FULL state. A `replace` - * page (initial load / full refresh) is the newest slice and replaces - * local state wholesale, globals included; a non-replace page is an older - * slice fetched with `before_turn` and prepended ahead of the loaded - * window (items only — globals stay with the fresher live state). - * - WS delta ops (`applyOps`): incremental `transcript.ops` only. Ops are - * idempotent upserts plus offset-placed appends, so ops buffered while a - * REST refresh is in flight converge when flushed onto the fresh pages. + * `system(undo)` / `system(clear)` land on the timeline in place AND + * truncate it: every entry whose own id is in `payload.removed_ids` is + * dropped together with its subtree (all entries carrying that turn_id), + * and interactions anchored at a removed tool call are cascaded out. * - * `onGap` surfaces `append` placement gaps so the caller can trigger a full - * REST refresh (the WS channel carries no snapshots to fall back on). + * State entities have one channel each: `interaction` / `task` / `todo` + * upsert into keyed maps, `session.state` replaces the single latest + * snapshot. Global messages (workspace/session/config/…) are not consumed + * by this store. + * + * An upsert whose `timestamp` is strictly older than the held entity's is + * skipped: a REST page folded before a live update must not rewind it. An + * upsert without a timestamp (an unread `user` message) is stale once the + * held entity carries one — unread precedes read, never the reverse — yet + * always outranks the page when a replace window carries live-only entries + * over. + * + * Notifications are trailing-edge throttled (`notifyIntervalMs`) so a + * per-token delta stream does not become a per-token React render; state + * reads (`getState`) always see the latest applied message regardless. */ -import { - applyOperation, - EMPTY_AGENT_STATE, - itemId, - type AgentState, - type TranscriptItem, - type TranscriptOperation, -} from '@moonshot-ai/transcript'; - -import type { TranscriptPage } from './api'; - -export function countTurns(items: readonly TranscriptItem[]): number { - let count = 0; - for (const item of items) if (item.kind === 'turn') count += 1; - return count; +import type { + AssistantMessage, + HistoryMessage, + InteractionMessage, + ServerMessage, + SessionStateMessage, + SystemMessage, + TaskMessage, + ThinkingMessage, + TodoMessage, + ToolCallMessage, +} from '@moonshot-ai/kap-server/protocol'; + +export type TimelineMessage = + | Extract + | Extract + | Extract + | AssistantMessage + | ThinkingMessage + | ToolCallMessage + | SystemMessage; + +export interface TimelineEntry { + readonly key: string; + readonly message: TimelineMessage; } -export function oldestTurnId(items: readonly TranscriptItem[]): string | undefined { - for (const item of items) if (item.kind === 'turn') return item.turnId; +export interface ChatState { + readonly entries: readonly TimelineEntry[]; + readonly interactions: ReadonlyMap; + readonly tasks: ReadonlyMap; + readonly todos: ReadonlyMap; + readonly sessionState: SessionStateMessage | undefined; + readonly hasMoreOlder: boolean; +} + +export const EMPTY_CHAT_STATE: ChatState = { + entries: [], + interactions: new Map(), + tasks: new Map(), + todos: new Map(), + sessionState: undefined, + hasMoreOlder: false, +}; + +export type HistoryPageMode = 'replace' | 'prepend' | 'tail'; + +export function timelineKeyOf(message: TimelineMessage): string { + switch (message.type) { + case 'turn': + return `turn:${message.turn_id}`; + case 'step': + return `step:${message.step_id}`; + case 'user': + case 'assistant': + case 'thinking': + return `${message.type}:${message.message_id}`; + case 'tool_call': + return `tool_call:${message.tool_call_id}`; + case 'system': + return `system:${message.system_id}`; + } +} + +function ownIdOf(message: TimelineMessage): string { + switch (message.type) { + case 'turn': + return message.turn_id; + case 'step': + return message.step_id; + case 'user': + case 'assistant': + case 'thinking': + return message.message_id; + case 'tool_call': + return message.tool_call_id; + case 'system': + return message.system_id; + } +} + +export function turnIdOf(message: TimelineMessage): string | undefined { + return message.type === 'system' ? undefined : message.turn_id; +} + +export function oldestTurnId(entries: readonly TimelineEntry[]): string | undefined { + for (const entry of entries) { + const turnId = turnIdOf(entry.message); + if (turnId !== undefined) return turnId; + } return undefined; } -export function hasTurnId(items: readonly TranscriptItem[], turnId: string): boolean { - return items.some((item) => item.kind === 'turn' && item.turnId === turnId); +export function hasTurnId(entries: readonly TimelineEntry[], turnId: string): boolean { + return entries.some((entry) => turnIdOf(entry.message) === turnId); +} + +export function newestTerminalStepId(entries: readonly TimelineEntry[]): string | undefined { + for (let i = entries.length - 1; i >= 0; i -= 1) { + const message = entries[i]!.message; + if (message.type === 'step' && message.status !== 'running') return message.step_id; + } + return undefined; } /** - * Re-cover a previously loaded window after a full refresh: page backwards - * until `prevOldestTurnId` (the window's oldest turn before the refresh) is - * loaded again. A count-based stop silently drops the window's head when new - * turns arrived meanwhile (the server window shifted, so the same count no - * longer reaches as far back). Stops at the oldest available page - * (`hasMoreOlder` false), on a no-progress page, or when `isDisposed`. + * Re-cover a previously loaded window after a replace-mode refresh: page + * backwards until `prevOldestTurnId` is loaded again (a count-based stop + * silently drops the window's head when new turns arrived meanwhile). Stops + * at the oldest available page, on a no-progress page, or when `isDisposed`. */ export async function recoverLoadedWindow( - store: TranscriptChatStore, + store: ChatStore, prevOldestTurnId: string | undefined, - fetchPage: (beforeTurn: string) => Promise, + fetchPage: (beforeTurn: string) => Promise, isDisposed: () => boolean, - onPageApplied?: (page: TranscriptPage) => void, + onPageApplied?: (beforeTurn: string, messages: readonly HistoryMessage[]) => void, ): Promise { if (prevOldestTurnId === undefined) return; - while (!hasTurnId(store.getState().items, prevOldestTurnId) && store.getState().hasMoreOlder) { - const oldest = oldestTurnId(store.getState().items); + while (!hasTurnId(store.getState().entries, prevOldestTurnId) && store.getState().hasMoreOlder) { + const oldest = oldestTurnId(store.getState().entries); if (oldest === undefined) break; - const before = countTurns(store.getState().items); + const before = store.getState().entries.length; const page = await fetchPage(oldest); if (isDisposed()) return; - store.applyPage(page); - onPageApplied?.(page); - if (countTurns(store.getState().items) === before) break; + store.applyHistoryPage(page, 'prepend'); + onPageApplied?.(oldest, page); + if (store.getState().entries.length === before) break; } } -/** - * Serialize refresh-style triggers: at most one run in flight; a trigger that - * arrives while a run is in flight is coalesced into exactly one follow-up run - * (so a subscribe ack landing mid-load still produces a post-load reconcile - * instead of being dropped). - */ -export function createCoalescedRunner(run: () => Promise): () => void { - let running = false; - let queued = false; - const kick = (): void => { - if (running) { - queued = true; - return; - } - running = true; - void run().finally(() => { - running = false; - if (queued) { - queued = false; - kick(); - } - }); - }; - return kick; -} - -export class TranscriptChatStore { - private state: AgentState = EMPTY_AGENT_STATE; +export class ChatStore { + private state: ChatState = EMPTY_CHAT_STATE; private readonly listeners = new Set<() => void>(); + private readonly notifyIntervalMs: number; + private notifyTimer: ReturnType | undefined; + private dirty = false; - /** Called when an `append` op could not be placed — the caller should refresh. */ - onGap: (() => void) | undefined; + constructor(opts?: { notifyIntervalMs?: number }) { + this.notifyIntervalMs = opts?.notifyIntervalMs ?? 80; + } - getState(): AgentState { + getState(): ChatState { return this.state; } - /** `useSyncExternalStore`-compatible subscribe. */ subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { @@ -117,59 +187,313 @@ export class TranscriptChatStore { }; }; + setHasMoreOlder(flag: boolean): void { + if (this.state.hasMoreOlder === flag) return; + this.state = { ...this.state, hasMoreOlder: flag }; + this.scheduleNotify(); + } + /** - * Merge one REST page. With `replace`, the page is the newest slice and - * becomes the whole state (initial load / full refresh); otherwise it is an - * older slice prepended ahead of the window (deduped by item id), updating - * only `items` and `hasMoreOlder`. + * Merge one REST history page. `replace` installs the page as the whole + * window (entries absent from it are dropped, except ones newer than the + * page's newest timestamp — live traffic that outran the fetch); + * `prepend` inserts the older slice ahead of the window (deduped by key); + * `tail` upserts the catch-up slice in page order. system(undo/clear) + * messages inside a page truncate exactly like live ones. */ - applyPage(page: TranscriptPage, opts?: { replace?: boolean }): void { - if (opts?.replace === true) { - this.state = { - items: page.items, - tasks: new Map(page.tasks.map((task) => [task.taskId, task])), - interactions: new Map( - page.interactions.map((interaction) => [interaction.interactionId, interaction]), - ), - attachments: new Map( - page.attachments.map((attachment) => [attachment.attachmentId, attachment]), - ), - todos: new Map(page.todos.map((todo) => [todo.todoId, todo])), - // The page contract carries no prompt slice yet; prompt.upsert ops - // still accumulate through the shared reducer between refreshes. - prompts: new Map(), - meta: page.meta, - pendingInteractions: new Set(page.pendingInteractions), - hasMoreOlder: page.hasMoreOlder, - }; - this.notify(); + applyHistoryPage(messages: readonly HistoryMessage[], mode: HistoryPageMode): void { + if (mode === 'replace') { + const pageMax = maxTimestamp(messages); + const carried = pageMax === undefined ? [] : this.newerThan(this.state.entries, pageMax); + const next: TimelineEntry[] = []; + const seen = new Set(); + for (const message of messages) { + if (!isTimelineMessage(message)) { + this.applyStateMessage(message); + continue; + } + const key = timelineKeyOf(message); + if (seen.has(key)) continue; + seen.add(key); + next.push(this.preferHeld(key, message)); + } + for (const entry of carried) { + if (!seen.has(entry.key)) next.push(entry); + } + this.state = { ...this.state, entries: next }; + this.applyTruncations(messages); + this.scheduleNotify(); return; } - const existing = new Set(this.state.items.map(itemId)); - const fresh = page.items.filter((item) => !existing.has(itemId(item))); - if (fresh.length === 0 && page.hasMoreOlder === this.state.hasMoreOlder) return; - this.state = { - ...this.state, - items: [...fresh, ...this.state.items], - hasMoreOlder: page.hasMoreOlder, - }; - this.notify(); - } - - /** Apply incremental WS ops; notifies once per changed batch. */ - applyOps(ops: readonly TranscriptOperation[]): void { - let changed = false; - for (const op of ops) { - const result = applyOperation(this.state, op); - if (result.gap !== undefined) this.onGap?.(); - if (!result.changed) continue; - this.state = result.state; - changed = true; + if (mode === 'prepend') { + const existing = new Set(this.state.entries.map((entry) => entry.key)); + const fresh: TimelineEntry[] = []; + for (const message of messages) { + if (!isTimelineMessage(message)) { + this.applyStateMessage(message); + continue; + } + const key = timelineKeyOf(message); + if (existing.has(key)) continue; + existing.add(key); + fresh.push({ key, message }); + } + if (fresh.length > 0) { + this.state = { ...this.state, entries: [...fresh, ...this.state.entries] }; + } + this.applyTruncations(messages); + this.scheduleNotify(); + return; + } + for (const message of messages) this.applyEntity(message); + } + + /** Apply one live (or recovery) WS message; recovery and live share this path. */ + applyLive(message: ServerMessage): void { + switch (message.type) { + case 'assistant.delta': { + this.patchText(`assistant:${message.message_id}`, message.text); + return; + } + case 'thinking.delta': { + this.patchText(`thinking:${message.message_id}`, message.text); + return; + } + case 'tool_call.delta': { + this.patchToolCall(message.tool_call_id, (call) => ({ + ...call, + input_text: (call.input_text ?? '') + message.input_text, + })); + return; + } + case 'tool.progress': { + this.patchToolCall(message.tool_call_id, (call) => ({ ...call, progress: message.progress })); + return; + } + case 'interaction': + case 'task': + case 'todo': + case 'session.state': { + this.applyStateMessage(message); + return; + } + case 'turn': + case 'step': + case 'user': + case 'assistant': + case 'thinking': + case 'tool_call': + case 'system': { + this.applyEntity(message); + return; + } + default: + return; } - if (changed) this.notify(); } - private notify(): void { + /** Flush a pending throttled notification (teardown / explicit sync point). */ + flushNotify(): void { + if (this.notifyTimer !== undefined) { + clearTimeout(this.notifyTimer); + this.notifyTimer = undefined; + } + if (!this.dirty) return; + this.dirty = false; for (const listener of this.listeners) listener(); } + + private applyEntity(message: HistoryMessage): void { + if (!isTimelineMessage(message)) { + this.applyStateMessage(message); + return; + } + const key = timelineKeyOf(message); + const index = this.state.entries.findIndex((entry) => entry.key === key); + if (index < 0) { + this.state = { ...this.state, entries: [...this.state.entries, { key, message }] }; + } else { + const held = this.state.entries[index]!.message; + if (held === message || isStaleUpsert(held.timestamp, message.timestamp)) return; + const entries = [...this.state.entries]; + entries[index] = { key, message }; + this.state = { ...this.state, entries }; + } + if (message.type === 'system' && (message.subtype === 'undo' || message.subtype === 'clear')) { + this.truncate(message); + } + this.scheduleNotify(); + } + + private applyStateMessage( + message: InteractionMessage | TaskMessage | TodoMessage | SessionStateMessage, + ): void { + switch (message.type) { + case 'interaction': { + const held = this.state.interactions.get(message.interaction_id); + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + const interactions = new Map([ + ...this.state.interactions, + [message.interaction_id, message] as const, + ]); + this.state = { ...this.state, interactions }; + break; + } + case 'task': { + const held = this.state.tasks.get(message.task_id); + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + const tasks = new Map([...this.state.tasks, [message.task_id, message] as const]); + this.state = { ...this.state, tasks }; + break; + } + case 'todo': { + const held = this.state.todos.get(message.todo_id); + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + const todos = new Map([...this.state.todos, [message.todo_id, message] as const]); + this.state = { ...this.state, todos }; + break; + } + case 'session.state': { + const held = this.state.sessionState; + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + this.state = { ...this.state, sessionState: message }; + break; + } + } + this.scheduleNotify(); + } + + private patchText(key: string, text: string): void { + this.patchEntry(key, (message) => { + if (message.type !== 'assistant' && message.type !== 'thinking') return message; + return { ...message, text: message.text + text }; + }); + } + + private patchToolCall( + toolCallId: string, + patch: (call: ToolCallMessage) => ToolCallMessage, + ): void { + this.patchEntry(`tool_call:${toolCallId}`, (message) => { + if (message.type !== 'tool_call') return message; + return patch(message); + }); + } + + private patchEntry(key: string, patch: (message: TimelineMessage) => TimelineMessage): void { + const index = this.state.entries.findIndex((entry) => entry.key === key); + if (index < 0) return; + const current = this.state.entries[index]!; + const next = patch(current.message); + if (next === current.message) return; + const entries = [...this.state.entries]; + entries[index] = { key, message: next }; + this.state = { ...this.state, entries }; + this.scheduleNotify(); + } + + private applyTruncations(messages: readonly HistoryMessage[]): void { + for (const message of messages) { + if (message.type === 'system' && (message.subtype === 'undo' || message.subtype === 'clear')) { + this.truncate(message); + } + } + } + + private truncate(message: SystemMessage): void { + if (message.subtype !== 'undo' && message.subtype !== 'clear') return; + const removed = new Set(message.payload.removed_ids); + if (removed.size === 0) return; + const removedToolCalls = new Set(); + const entries = this.state.entries.filter((entry) => { + const current = entry.message; + if (removed.has(ownIdOf(current))) { + if (current.type === 'tool_call') removedToolCalls.add(current.tool_call_id); + return false; + } + if (current.type !== 'system' && current.turn_id !== undefined && removed.has(current.turn_id)) { + if (current.type === 'tool_call') removedToolCalls.add(current.tool_call_id); + return false; + } + return true; + }); + let interactions = this.state.interactions; + if (removedToolCalls.size > 0) { + const next = new Map(interactions); + for (const [id, interaction] of next) { + if (interaction.tool_call_id !== undefined && removedToolCalls.has(interaction.tool_call_id)) { + next.delete(id); + } + } + interactions = next; + } + this.state = { ...this.state, entries, interactions }; + } + + private preferHeld(key: string, message: TimelineMessage): TimelineEntry { + const held = this.state.entries.find((entry) => entry.key === key); + if (held !== undefined && isStaleUpsert(held.message.timestamp, message.timestamp)) return held; + return { key, message }; + } + + private newerThan(entries: readonly TimelineEntry[], timestamp: number): TimelineEntry[] { + return entries.filter( + (entry) => entry.message.timestamp === undefined || entry.message.timestamp > timestamp, + ); + } + + private scheduleNotify(): void { + this.dirty = true; + if (this.notifyIntervalMs <= 0) { + this.flushNotify(); + return; + } + if (this.notifyTimer !== undefined) return; + this.notifyTimer = setTimeout(() => { + this.notifyTimer = undefined; + this.flushNotify(); + }, this.notifyIntervalMs); + this.notifyTimer.unref?.(); + } +} + +function isTimelineMessage( + message: HistoryMessage | ServerMessage, +): message is TimelineMessage { + switch (message.type) { + case 'turn': + case 'step': + case 'user': + case 'assistant': + case 'thinking': + case 'tool_call': + case 'system': + return true; + default: + return false; + } +} + +function maxTimestamp(messages: readonly HistoryMessage[]): number | undefined { + let max: number | undefined; + for (const message of messages) { + if (message.timestamp === undefined) continue; + if (max === undefined || message.timestamp > max) max = message.timestamp; + } + return max; +} + +/** + * Same-entity version ordering for the idempotent upsert path. Only `user` + * messages can lack a timestamp (unread = not persisted yet), and the + * unread → read transition is one-way, so an untimestamped upsert is stale + * whenever the held entity already carries one. + */ +function isStaleUpsert(held: number | undefined, incoming: number | undefined): boolean { + if (incoming === undefined) return held !== undefined; + return held !== undefined && held > incoming; } diff --git a/apps/kimi-inspect/src/transcript/transcript.test.ts b/apps/kimi-inspect/src/transcript/transcript.test.ts index 507d5c15e94..07dba90ccf9 100644 --- a/apps/kimi-inspect/src/transcript/transcript.test.ts +++ b/apps/kimi-inspect/src/transcript/transcript.test.ts @@ -1,117 +1,180 @@ /** - * Transcript glue-layer tests — the app's own REST/WS/store plumbing. The L2 - * reducer semantics themselves are covered by `@moonshot-ai/transcript`'s own - * test suite and are intentionally not re-tested here. + * Message-protocol glue-layer tests — the app's own REST/WS/store/channel + * plumbing for the v3 protocol. The wire schemas themselves are covered by + * kap-server's contract tests and are intentionally not re-tested here. */ -import { - itemId, - type StepHeader, - type TranscriptOperation, - type TranscriptTurn, - type TurnHeader, - type TurnState, -} from '@moonshot-ai/transcript'; +import type { + AssistantMessage, + HistoryMessage, + InteractionMessage, + ServerMessage, + StepMessage, + SystemMessage, + TaskMessage, + ToolCallMessage, + TurnMessage, + UserMessage, +} from '@moonshot-ai/kap-server/protocol'; import { describe, expect, it, vi } from 'vitest'; import type { WsLike } from '../channel/wsLike'; +import { fetchFullHistory, fetchHistoryPage } from './api'; +import { ChatChannel } from './channel'; +import { projectPlans } from './plan'; import { - fetchTranscriptAttachment, - fetchTranscriptOps, - fetchTranscriptPage, - fetchTranscriptPlan, - transcriptAttachmentUrl, - type TranscriptPage, -} from './api'; -import { - countTurns, - createCoalescedRunner, + ChatStore, + newestTerminalStepId, oldestTurnId, recoverLoadedWindow, - TranscriptChatStore, + type TimelineEntry, } from './store'; -import { TranscriptWs } from './ws'; +import { ChatWs } from './ws'; // ---------------------------------------------------------------- fixtures -function turnHeader(n: number, state: TurnState = 'completed'): TurnHeader { - return { kind: 'turn', turnId: `t${n}`, ordinal: n, state, origin: { kind: 'user' } }; +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +let tick = 0; + +function ts(offsetMs?: number): number { + tick += 1; + return T0 + tick * 1000 + (offsetMs ?? 0); } -function turnItem(n: number): TranscriptTurn { - return { ...turnHeader(n), steps: [] }; +const base = { session_id: 's1', agent_id: 'main' } as const; + +function turnMsg(n: number, status: 'running' | 'completed' = 'completed', at?: number): TurnMessage { + return { + type: 'turn', + ...base, + timestamp: at ?? ts(), + turn_id: `t${n}`, + ordinal: n, + status, + origin: { kind: 'user' }, + }; } -function stepHeader(stepId: string, ordinal: number): StepHeader { - return { kind: 'step', stepId, turnId: stepId.split('.')[0] ?? 't1', ordinal, state: 'running' }; +function stepMsg( + stepId: string, + status: StepMessage['status'] = 'completed', + at?: number, +): StepMessage { + const turnId = stepId.split('.')[0] ?? 't1'; + const ordinal = Number(stepId.split('.')[1] ?? '1'); + return { + type: 'step', + ...base, + timestamp: at ?? ts(), + step_id: stepId, + turn_id: turnId, + ordinal, + status, + }; } -describe('transcript attachments', () => { - it('maps each attachment locator to its transport route', () => { - expect( - transcriptAttachmentUrl('http://h:1', 's 1', { kind: 'file', fileId: 'f 1' }), - ).toBe('http://h:1/api/v1/files/f%201'); - expect( - transcriptAttachmentUrl('http://h:1', 's 1', { - kind: 'session_media', - fileId: 'f 1', - }), - ).toBe('http://h:1/api/v1/sessions/s%201/media/f%201'); - expect( - transcriptAttachmentUrl('http://h:1', 's1', { - kind: 'url', - url: 'https://example.com/a.png', - }), - ).toBe('https://example.com/a.png'); - }); +function userMsg(stepId: string, text: string, at?: number): UserMessage { + const turnId = stepId.split('.')[0] ?? 't1'; + return { + type: 'user', + ...base, + timestamp: at ?? ts(), + message_id: `${stepId}.u0`, + turn_id: turnId, + text: [{ type: 'text', text, meta: {} }], + status: 'read', + }; +} - it('fetches stored attachment bytes with bearer auth', async () => { - const fetchImpl = vi.fn(async () => new Response('media-bytes', { status: 200 })); +function assistantMsg( + stepId: string, + text: string, + status: 'streaming' | 'completed' = 'completed', + at?: number, +): AssistantMessage { + const turnId = stepId.split('.')[0] ?? 't1'; + return { + type: 'assistant', + ...base, + timestamp: at ?? ts(), + message_id: `${stepId}.a0`, + turn_id: turnId, + step_id: stepId, + status, + text, + }; +} - const blob = await fetchTranscriptAttachment({ - baseUrl: 'http://h:1', - token: 'tok', - sessionId: 's1', - source: { kind: 'session_media', fileId: 'f_1' }, - fetchImpl: fetchImpl as typeof fetch, - }); +function toolCallMsg( + stepId: string, + id: string, + overrides: Partial = {}, +): ToolCallMessage { + const turnId = stepId.split('.')[0] ?? 't1'; + return { + type: 'tool_call', + ...base, + timestamp: ts(), + tool_call_id: id, + turn_id: turnId, + step_id: stepId, + name: 'Bash', + status: 'running', + ...overrides, + }; +} - expect(fetchImpl).toHaveBeenCalledWith( - 'http://h:1/api/v1/sessions/s1/media/f_1', - { headers: { authorization: 'Bearer tok' } }, - ); - await expect(blob.text()).resolves.toBe('media-bytes'); - }); -}); +function systemMsg( + subtype: SystemMessage['subtype'], + systemId: string, + payload?: unknown, +): SystemMessage { + return { + type: 'system', + ...base, + timestamp: ts(), + system_id: systemId, + subtype, + payload, + } as SystemMessage; +} -const textFrameUpsert = (turnId: string, stepId: string, frameId: string, text: string) => ({ - op: 'frame.upsert' as const, - turnId, - stepId, - frame: { kind: 'text' as const, frameId, role: 'assistant' as const, text }, -}); +function interactionMsg(id: string, toolCallId?: string): InteractionMessage { + return { + type: 'interaction', + ...base, + timestamp: ts(), + interaction_id: id, + kind: 'approval', + status: 'pending', + tool_call_id: toolCallId, + }; +} -const frameAppend = ( - turnId: string, - stepId: string, - frameId: string, - offset: number, - text: string, -) => ({ - op: 'append' as const, - target: { type: 'frame' as const, turnId, stepId, frameId }, - offset, - text, -}); +function taskMsg(id: string, status: TaskMessage['status'] = 'running'): TaskMessage { + return { + type: 'task', + ...base, + timestamp: ts(), + task_id: id, + kind: 'shell', + status, + detached: false, + output_tail: '', + }; +} -const emptyPage = { - tasks: [], - interactions: [], - attachments: [], - todos: [], - meta: {}, - pendingInteractions: [], -} as const; +function undoMsg(systemId: string, removedIds: readonly string[]): SystemMessage { + return systemMsg('undo', systemId, { removed_ids: [...removedIds] }); +} + +function entryKeys(entries: readonly TimelineEntry[]): string[] { + return entries.map((entry) => entry.key); +} + +function makeStore(): ChatStore { + return new ChatStore({ notifyIntervalMs: 0 }); +} function okEnvelope(data: unknown) { return { code: 0, msg: 'success', data, request_id: 'r1' }; @@ -173,42 +236,52 @@ class FakeWs implements WsLike { sentFrames(): Record[] { return this.sent.map((data) => JSON.parse(data) as Record); } + + hello(): void { + this.serverFrame({ + type: 'hello', + protocol_version: '3', + server_id: 'srv', + capabilities: ['step_replay_v1'], + }); + } } -function makeWs(handlers: Partial[0]['handlers']> = {}) { +function makeWs(handlers: Partial[0]['handlers']> = {}) { const seen = { - ops: [] as { - agentId: string; - ops: readonly TranscriptOperation[]; - at?: string; - seq?: number; - }[], - resets: [] as { agentId: string; hasMoreOlder: boolean; at?: string; seq?: number }[], - resyncs: 0, + messages: [] as ServerMessage[], + acks: [] as { code: number; msg?: string }[], + protocolErrors: [] as { code: number; msg: string }[], + invalid: 0, reconnects: 0, }; - const ws = new TranscriptWs({ + const ws = new ChatWs({ url: 'http://h:1', token: 'tok', sessionId: 's1', - agentId: 'main', + agentIds: ['main'], WebSocketImpl: FakeWs, + reconnectDelayMs: 1, handlers: { - onOps: (agentId, ops, meta) => { - seen.ops.push({ agentId, ops, at: meta?.at, seq: meta?.seq }); - handlers.onOps?.(agentId, ops, meta); + onMessage: (message) => { + seen.messages.push(message); + handlers.onMessage?.(message); + }, + onAck: (code, msg) => { + seen.acks.push({ code, msg }); + handlers.onAck?.(code, msg); }, - onReset: (agentId, _snapshot, hasMoreOlder, meta) => { - seen.resets.push({ agentId, hasMoreOlder, at: meta?.at, seq: meta?.seq }); - handlers.onReset?.(agentId, _snapshot, hasMoreOlder, meta); + onProtocolError: (code, msg) => { + seen.protocolErrors.push({ code, msg }); + handlers.onProtocolError?.(code, msg); }, - onResyncRequired: () => { - seen.resyncs += 1; - handlers.onResyncRequired?.(); + onInvalidFrame: () => { + seen.invalid += 1; + handlers.onInvalidFrame?.(null); }, - onReconnected: () => { + onReconnectScheduled: () => { seen.reconnects += 1; - handlers.onReconnected?.(); + handlers.onReconnectScheduled?.(0); }, }, }); @@ -217,416 +290,169 @@ function makeWs(handlers: Partial[0][ // ---------------------------------------------------------------- api -describe('fetchTranscriptPage', () => { +describe('fetchHistoryPage', () => { const pageData = { - agent_id: 'main', - items: [turnItem(1)], - has_more: true, - tasks: [ - { taskId: 'bash-1', kind: 'shell', state: 'running', detached: false, outputTail: 'x' }, - ], - interactions: [], - attachments: [], - todos: [], - meta: { activity: 'turn' }, - agents: [], - pending_interactions: ['apr-1'], - seq: 42, + messages: [turnMsg(1)], + has_more: false, + in_flight: { turn_id: 't1', step_id: 't1.2' }, }; it('requests the endpoint with cursor params and bearer auth, unwraps the envelope', async () => { const { calls, fetchImpl } = fakeFetch(okEnvelope(pageData)); - const page = await fetchTranscriptPage({ + const page = await fetchHistoryPage({ baseUrl: 'http://h:1', token: 'tok', sessionId: 's 1', agentId: 'main', beforeTurn: 't5', + pageSize: 50, fetchImpl, }); expect(calls).toHaveLength(1); - expect(calls[0]!.url).toContain('/api/v1/sessions/s%201/transcript?'); + expect(calls[0]!.url).toContain('/api/v1/sessions/s%201/history?'); expect(calls[0]!.url).toContain('agent_id=main'); expect(calls[0]!.url).toContain('before_turn=t5'); - expect(calls[0]!.url).toContain('page_size=1'); + expect(calls[0]!.url).toContain('page_size=50'); expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' }); - expect(page.hasMoreOlder).toBe(true); - expect(page.items.map((item) => itemId(item))).toEqual(['t1']); - expect(page.tasks.map((task) => task.taskId)).toEqual(['bash-1']); - expect(page.meta.activity).toBe('turn'); - expect(page.pendingInteractions).toEqual(['apr-1']); - expect(page.seq).toBe(42); + expect(page.messages).toHaveLength(1); + expect(page.inFlight).toEqual({ turn_id: 't1', step_id: 't1.2' }); }); - it('throws on a non-zero envelope code', async () => { - const { fetchImpl } = fakeFetch({ code: 40401, msg: 'session not found', data: null }); - await expect( - fetchTranscriptPage({ baseUrl: 'http://h:1', sessionId: 's9', agentId: 'main', fetchImpl }), - ).rejects.toThrow('session not found'); - }); - - it('throws when the payload fails schema validation', async () => { - const { fetchImpl } = fakeFetch(okEnvelope({ agent_id: 'main', items: 'nope' })); - await expect( - fetchTranscriptPage({ baseUrl: 'http://h:1', sessionId: 's1', agentId: 'main', fetchImpl }), - ).rejects.toThrow('unexpected response shape'); - }); -}); - -// ---------------------------------------------------------------- ops catch-up - -describe('fetchTranscriptOps', () => { - const catchupData = { - agent_id: 'main', - batches: [ - { seq: 6, ops: [{ op: 'meta.merge', meta: { activity: 'turn' } }] }, - { seq: 7, ops: [{ op: 'turn.upsert', turn: turnHeader(7, 'running') }] }, - ], - latest_seq: 7, - complete: true, - }; - - it('requests the ops endpoint with since_seq and unwraps batches in order', async () => { - const { calls, fetchImpl } = fakeFetch(okEnvelope(catchupData)); - const res = await fetchTranscriptOps({ + it('sends after_step and omits unset cursors', async () => { + const { calls, fetchImpl } = fakeFetch(okEnvelope({ messages: [], has_more: false })); + await fetchHistoryPage({ baseUrl: 'http://h:1', - token: 'tok', sessionId: 's1', agentId: 'main', - sinceSeq: 5, + afterStep: 't1.3', fetchImpl, }); - expect(calls[0]!.url).toContain('/api/v1/sessions/s1/transcript/ops?'); - expect(calls[0]!.url).toContain('agent_id=main'); - expect(calls[0]!.url).toContain('since_seq=5'); - expect(res.complete).toBe(true); - expect(res.latestSeq).toBe(7); - expect(res.batches.map((batch) => batch.seq)).toEqual([6, 7]); + expect(calls[0]!.url).toContain('after_step=t1.3'); + expect(calls[0]!.url).not.toContain('before_turn'); + expect(calls[0]!.init?.headers).toEqual({}); }); - it('surfaces an incomplete catch-up (journal cannot cover)', async () => { - const { fetchImpl } = fakeFetch( - okEnvelope({ ...catchupData, batches: [], latest_seq: 500, complete: false }), - ); - const res = await fetchTranscriptOps({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - sinceSeq: 5, - fetchImpl, - }); - expect(res.complete).toBe(false); - expect(res.batches).toEqual([]); - }); - - it('throws on a legacy server (envelope error) so callers fall back', async () => { - const { fetchImpl } = fakeFetch({ code: 40404, msg: 'unknown route', data: null }); + it('throws on a non-zero envelope code', async () => { + const { fetchImpl } = fakeFetch({ code: 40401, msg: 'session not found', data: null }); await expect( - fetchTranscriptOps({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - sinceSeq: 5, - fetchImpl, - }), - ).rejects.toThrow('unknown route'); + fetchHistoryPage({ baseUrl: 'http://h:1', sessionId: 's9', agentId: 'main', fetchImpl }), + ).rejects.toThrow('session not found'); }); -}); -// ------------------------------------------------------------------ plan lookup - -describe('fetchTranscriptPlan', () => { - const planEntry = { - tool_call_id: 'call_plan', - turn_id: 't3', - source: 'interaction', - plan: '# The Plan\n\nDo the thing.', - path: '/tmp/plans/foo.md', - options: [{ label: 'Approach A', description: 'fast' }], - review: { state: 'approved', selected_option: 'Approach A', feedback: 'looks good' }, - }; - - it('requests the plan endpoint with agent_id/tool_call_id and maps the snake_case payload', async () => { - const { calls, fetchImpl } = fakeFetch(okEnvelope({ agent_id: 'main', plans: [planEntry] })); - const plans = await fetchTranscriptPlan({ - baseUrl: 'http://h:1', - token: 'tok', - sessionId: 's 1', - agentId: 'main', - toolCallId: 'call_plan', - fetchImpl, - }); - expect(calls).toHaveLength(1); - expect(calls[0]!.url).toContain('/api/v1/sessions/s%201/transcript/plan?'); - expect(calls[0]!.url).toContain('agent_id=main'); - expect(calls[0]!.url).toContain('tool_call_id=call_plan'); - expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' }); - expect(plans).toEqual([ - { - toolCallId: 'call_plan', - turnId: 't3', - source: 'interaction', - plan: '# The Plan\n\nDo the thing.', - path: '/tmp/plans/foo.md', - options: [{ label: 'Approach A', description: 'fast' }], - review: { state: 'approved', selectedOption: 'Approach A', feedback: 'looks good' }, - }, - ]); + it('throws when the payload fails schema validation', async () => { + const { fetchImpl } = fakeFetch(okEnvelope({ messages: 'nope' })); + await expect( + fetchHistoryPage({ baseUrl: 'http://h:1', sessionId: 's1', agentId: 'main', fetchImpl }), + ).rejects.toThrow('unexpected response shape'); }); - it('omits tool_call_id from the query when unset (lists every plan of the agent)', async () => { - const { calls, fetchImpl } = fakeFetch( - okEnvelope({ - agent_id: 'main', - plans: [ - { tool_call_id: 'call_draft', turn_id: 't1', source: 'display', plan: '# Draft' }, - { tool_call_id: 'call_final', turn_id: 't2', source: 'output', plan: '# Final' }, - ], - }), - ); - const plans = await fetchTranscriptPlan({ + it('fetchFullHistory pages before_turn to the beginning and returns timeline order', async () => { + const pages: Record = { + newest: okEnvelope({ messages: [turnMsg(3), stepMsg('t3.1')], has_more: true }), + 't3': okEnvelope({ messages: [turnMsg(1), turnMsg(2)], has_more: false }), + }; + const calls: string[] = []; + const fetchImpl = (async (url: string | URL) => { + const text = String(url); + calls.push(text); + const before = /before_turn=([^&]+)/.exec(text)?.[1]; + const envelope = before === undefined ? pages['newest'] : (pages[before] ?? okEnvelope({ messages: [], has_more: false })); + return { json: async () => envelope }; + }) as unknown as typeof fetch; + const messages = await fetchFullHistory({ baseUrl: 'http://h:1', sessionId: 's1', agentId: 'main', + pageSize: 2, fetchImpl, }); - expect(calls[0]!.url).not.toContain('tool_call_id'); - expect(plans.map((p) => [p.toolCallId, p.plan])).toEqual([ - ['call_draft', '# Draft'], - ['call_final', '# Final'], - ]); - expect(plans[0]!.review).toBeUndefined(); - expect(plans[0]!.path).toBeUndefined(); - expect(plans[0]!.options).toBeUndefined(); - }); - - it('throws on a 40416 envelope (unknown tool call / not ExitPlanMode)', async () => { - const { fetchImpl } = fakeFetch({ - code: 40416, - msg: 'no ExitPlanMode tool call found for tool_call_id: call_nope', - data: null, - }); - await expect( - fetchTranscriptPlan({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - toolCallId: 'call_nope', - fetchImpl, - }), - ).rejects.toThrow('40416'); - }); - - it('throws when the payload fails schema validation', async () => { - const { fetchImpl } = fakeFetch(okEnvelope({ agent_id: 'main', plans: 'nope' })); - await expect( - fetchTranscriptPlan({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - fetchImpl, - }), - ).rejects.toThrow('unexpected response shape'); + expect(calls).toHaveLength(3); + expect(calls[1]).toContain('before_turn=t3'); + expect(calls[2]).toContain('before_turn=t1'); + expect(messages.map((m) => ('turn_id' in m ? m.turn_id : ''))).toEqual(['t1', 't2', 't3', 't3']); }); }); // ---------------------------------------------------------------- ws -describe('TranscriptWs', () => { - it('connects with the bearer subprotocol and sends the grade spec via subscribe_v2', () => { +describe('ChatWs', () => { + it('connects with the bearer subprotocol and subscribes after the server hello', () => { FakeWs.reset(); makeWs(); const sock = FakeWs.instances[0]!; - expect(sock.url).toBe('ws://h:1/api/v1/ws'); + expect(sock.url).toBe('ws://h:1/api/v3/ws'); expect(sock.protocols).toEqual(['kimi-code.bearer.tok']); sock.open(); - expect(sock.sentFrames()[0]).toMatchObject({ - type: 'client_hello', - payload: { - subscriptions: ['s1'], - }, - }); - expect(sock.sentFrames()[1]).toMatchObject({ - type: 'subscribe_v2', - payload: { - session_id: 's1', - transcript: { main: 'block' }, - }, + expect(sock.sent).toHaveLength(0); + sock.hello(); + expect(sock.sentFrames()[0]).toEqual({ + type: 'subscribe', + id: 1, + session_id: 's1', + agent_ids: ['main'], }); }); - it('forwards transcript.ops and surfaces transcript.reset via onReset, both with envelope meta', () => { + it('fires onAck on the subscribe ack and forwards entity messages', () => { FakeWs.reset(); const { seen } = makeWs(); const sock = FakeWs.instances[0]!; sock.open(); + sock.hello(); + sock.serverFrame({ type: 'ack', id: 1, code: 0 }); + expect(seen.acks).toEqual([{ code: 0 }]); + sock.serverFrame(turnMsg(1, 'running')); sock.serverFrame({ - type: 'transcript.reset', - seq: 1, - volatile: true, - session_id: 's1', - timestamp: '2026-01-01T00:00:00Z', - payload: { - type: 'transcript.reset', - agent_id: 'main', - snapshot: { items: [], tasks: [], interactions: [], meta: {} }, - has_more_older: true, - seq: 41, - }, - }); - expect(seen.ops).toHaveLength(0); - expect(seen.resets).toEqual([ - { agentId: 'main', hasMoreOlder: true, at: '2026-01-01T00:00:00Z', seq: 41 }, - ]); - sock.serverFrame({ - type: 'transcript.ops', - seq: 1, - volatile: true, + type: 'session.state', session_id: 's1', - timestamp: '2026-01-01T00:00:01Z', - payload: { - type: 'transcript.ops', - agent_id: 'main', - ops: [{ op: 'meta.merge', meta: { activity: 'turn' } }], - seq: 42, - }, + timestamp: ts(), + status: 'idle', }); - expect(seen.ops).toHaveLength(1); - expect(seen.ops[0]!.agentId).toBe('main'); - expect(seen.ops[0]!.at).toBe('2026-01-01T00:00:01Z'); - expect(seen.ops[0]!.seq).toBe(42); - expect(seen.ops[0]!.ops[0]).toMatchObject({ op: 'meta.merge' }); + expect(seen.messages.map((m) => m.type)).toEqual(['turn', 'session.state']); }); - it('sends a clean client_hello and carries grades/transcript_since on subscribe_v2', async () => { + it('surfaces protocol error frames and ignores acks for other ids', () => { FakeWs.reset(); - let watermark: number | undefined; - new TranscriptWs({ - url: 'http://h:1', - sessionId: 's1', - agentId: 'main', - WebSocketImpl: FakeWs, - getSince: () => watermark, - reconnectDelayMs: 1, - handlers: { onOps: () => {}, onResyncRequired: () => {}, onReconnected: () => {} }, - }); - const sock = FakeWs.instances[0]!; - sock.open(); - expect(sock.sentFrames()[0]).toMatchObject({ - type: 'client_hello', - payload: { client_id: 'kimi-inspect', subscriptions: ['s1'] }, - }); - expect(sock.sentFrames()[0]).not.toHaveProperty('payload.transcript'); - expect(sock.sentFrames()[1]).toMatchObject({ - type: 'subscribe_v2', - payload: { session_id: 's1', transcript: { main: 'block' } }, - }); - expect( - (sock.sentFrames()[1] as { payload: Record }).payload['transcript_since'], - ).toBeUndefined(); - watermark = 42; - sock.emit('close'); - await vi.waitFor(() => { - expect(FakeWs.instances.length).toBeGreaterThan(1); - }); - const second = FakeWs.instances[1]!; - second.open(); - expect(second.sentFrames()[1]).toMatchObject({ - type: 'subscribe_v2', - payload: { session_id: 's1', transcript_since: { main: 42 } }, - }); - }); - - it('still ignores transcript.reset when no onReset handler is set', () => { - FakeWs.reset(); - const seen = { ops: 0 }; - new TranscriptWs({ - url: 'http://h:1', - sessionId: 's1', - agentId: 'main', - WebSocketImpl: FakeWs, - handlers: { - onOps: () => { - seen.ops += 1; - }, - onResyncRequired: () => {}, - onReconnected: () => {}, - }, - }); + const { seen } = makeWs(); const sock = FakeWs.instances[0]!; sock.open(); - sock.serverFrame({ - type: 'transcript.reset', - timestamp: '2026-01-01T00:00:00Z', - payload: { - type: 'transcript.reset', - agent_id: 'main', - snapshot: { items: [], tasks: [], interactions: [], meta: {} }, - has_more_older: false, - }, - }); - expect(seen.ops).toBe(0); + sock.hello(); + sock.serverFrame({ type: 'ack', id: 99, code: 0 }); + expect(seen.acks).toHaveLength(0); + sock.serverFrame({ type: 'error', code: 1008, msg: 'slow consumer' }); + expect(seen.protocolErrors).toEqual([{ code: 1008, msg: 'slow consumer' }]); }); - it('answers ping with pong carrying the nonce', () => { + it('ignores unknown future message types but reports malformed known ones', () => { FakeWs.reset(); - makeWs(); + const { seen } = makeWs(); const sock = FakeWs.instances[0]!; sock.open(); - sock.serverFrame({ type: 'ping', timestamp: '2026-01-01T00:00:00Z', payload: { nonce: 'n1' } }); - expect(sock.sentFrames().at(-1)).toEqual({ type: 'pong', payload: { nonce: 'n1' } }); + sock.hello(); + sock.serverFrame({ type: 'turn.supercharged', whatever: true }); + sock.serverFrame({ type: 'turn', turn_id: 42 }); + expect(seen.messages).toHaveLength(0); + expect(seen.invalid).toBe(1); }); - it('surfaces resync_required for its session (and ignores other sessions)', () => { + it('re-subscribes after a drop and fires onAck per subscribe', async () => { FakeWs.reset(); const { seen } = makeWs(); - const sock = FakeWs.instances[0]!; - sock.open(); - sock.serverFrame({ - type: 'resync_required', - timestamp: '2026-01-01T00:00:00Z', - payload: { session_id: 'other', reason: 'buffer_overflow', current_seq: 5 }, - }); - expect(seen.resyncs).toBe(0); - sock.serverFrame({ - type: 'resync_required', - timestamp: '2026-01-01T00:00:00Z', - payload: { session_id: 's1', reason: 'buffer_overflow', current_seq: 5 }, + const first = FakeWs.instances[0]!; + first.open(); + first.hello(); + first.serverFrame({ type: 'ack', id: 1, code: 0 }); + expect(seen.acks).toHaveLength(1); + first.emit('close'); + await vi.waitFor(() => { + expect(FakeWs.instances.length).toBeGreaterThan(1); }); - expect(seen.resyncs).toBe(1); - }); - - it('re-subscribes after a drop and reports the reconnect only on the subscribe_v2 ack', () => { - vi.useFakeTimers(); - try { - FakeWs.reset(); - const { seen } = makeWs(); - const first = FakeWs.instances[0]!; - first.open(); - // Open alone does not reconcile: the server attaches the transcript - // stream only after processing subscribe_v2. - expect(seen.reconnects).toBe(0); - // Neither does the client_hello ack. - const helloId = (first.sentFrames()[0] as { id: string }).id; - first.serverFrame({ type: 'ack', id: helloId, code: 0, msg: 'success', payload: {} }); - expect(seen.reconnects).toBe(0); - const subscribeV2Id = (first.sentFrames()[1] as { id: string }).id; - first.serverFrame({ type: 'ack', id: subscribeV2Id, code: 0, msg: 'success', payload: {} }); - expect(seen.reconnects).toBe(1); - first.emit('close'); - vi.advanceTimersByTime(600); - expect(FakeWs.instances).toHaveLength(2); - const second = FakeWs.instances[1]!; - second.open(); - expect(second.sentFrames()[0]).toMatchObject({ type: 'client_hello' }); - expect(second.sentFrames()[1]).toMatchObject({ type: 'subscribe_v2' }); - expect(seen.reconnects).toBe(1); - const subscribeV2Id2 = (second.sentFrames()[1] as { id: string }).id; - second.serverFrame({ type: 'ack', id: subscribeV2Id2, code: 0, msg: 'success', payload: {} }); - expect(seen.reconnects).toBe(2); - } finally { - vi.useRealTimers(); - } + const second = FakeWs.instances[1]!; + second.open(); + second.hello(); + expect(second.sentFrames()[0]).toMatchObject({ type: 'subscribe', id: 2 }); + second.serverFrame({ type: 'ack', id: 2, code: 0 }); + expect(seen.acks).toHaveLength(2); }); it('stays closed after close()', () => { @@ -640,276 +466,437 @@ describe('TranscriptWs', () => { // ---------------------------------------------------------------- store -describe('TranscriptChatStore', () => { - it('applyPage(replace) installs the newest slice wholesale (items + globals)', () => { - const store = new TranscriptChatStore(); - store.applyOps([{ op: 'turn.upsert', turn: turnHeader(9, 'running') }]); - store.applyPage( - { - ...emptyPage, - items: [turnItem(1), turnItem(2)], - hasMoreOlder: true, - tasks: [ - { taskId: 'bash-1', kind: 'shell', state: 'running', detached: false, outputTail: '' }, - ], - meta: { activity: 'idle' }, - pendingInteractions: ['apr-1'], - }, - { replace: true }, - ); +describe('ChatStore', () => { + it('upserts entities by (type, id) and replaces in place', () => { + const store = makeStore(); + store.applyLive(turnMsg(1, 'running')); + store.applyLive(stepMsg('t1.1', 'running')); + store.applyLive(turnMsg(1, 'completed')); const state = store.getState(); - expect(state.items.map((item) => itemId(item))).toEqual(['t1', 't2']); - expect(state.hasMoreOlder).toBe(true); - expect(state.tasks.get('bash-1')?.kind).toBe('shell'); - expect(state.meta.activity).toBe('idle'); - expect([...state.pendingInteractions]).toEqual(['apr-1']); - }); - - it('prepends older pages ahead of the window, dedupes, keeps live globals', () => { - const store = new TranscriptChatStore(); - store.applyPage( - { ...emptyPage, items: [turnItem(3)], hasMoreOlder: true, meta: { activity: 'idle' } }, - { replace: true }, - ); - store.applyPage({ - ...emptyPage, - items: [turnItem(1), turnItem(2)], - hasMoreOlder: true, - meta: {}, + expect(entryKeys(state.entries)).toEqual(['turn:t1', 'step:t1.1']); + const turn = state.entries[0]!.message as TurnMessage; + expect(turn.status).toBe('completed'); + }); + + it('skips an upsert whose timestamp is older than the held entity', () => { + const store = makeStore(); + store.applyLive(assistantMsg('t1.1', 'hello world', 'streaming', Date.parse('2026-01-01T00:00:10.000Z'))); + store.applyLive(assistantMsg('t1.1', 'hel', 'streaming', Date.parse('2026-01-01T00:00:05.000Z'))); + const held = store.getState().entries[0]!.message as AssistantMessage; + expect(held.text).toBe('hello world'); + }); + + it('appends deltas to the held entity and drops orphan deltas', () => { + const store = makeStore(); + store.applyLive({ + type: 'assistant.delta', + ...base, + timestamp: ts(), + message_id: 't1.1.a0', + text: 'orphan', + }); + expect(store.getState().entries).toHaveLength(0); + store.applyLive(assistantMsg('t1.1', '', 'streaming')); + store.applyLive({ + type: 'assistant.delta', + ...base, + timestamp: ts(), + message_id: 't1.1.a0', + text: 'hel', + }); + store.applyLive({ + type: 'assistant.delta', + ...base, + timestamp: ts(), + message_id: 't1.1.a0', + text: 'lo', + }); + const held = store.getState().entries[0]!.message as AssistantMessage; + expect(held.text).toBe('hello'); + }); + + it('treats an entity arrival after deltas as the authoritative whole', () => { + const store = makeStore(); + store.applyLive(assistantMsg('t1.1', '', 'streaming')); + store.applyLive({ + type: 'assistant.delta', + ...base, + timestamp: ts(), + message_id: 't1.1.a0', + text: 'partial', + }); + store.applyLive(assistantMsg('t1.1', 'partial but authoritative', 'completed')); + const held = store.getState().entries[0]!.message as AssistantMessage; + expect(held.text).toBe('partial but authoritative'); + expect(held.status).toBe('completed'); + }); + + it('appends tool_call deltas to input_text and patches tool.progress', () => { + const store = makeStore(); + store.applyLive(toolCallMsg('t1.1', 'call_1', { input_text: '' })); + store.applyLive({ + type: 'tool_call.delta', + ...base, + timestamp: ts(), + tool_call_id: 'call_1', + input_text: '{"command"', + }); + store.applyLive({ + type: 'tool_call.delta', + ...base, + timestamp: ts(), + tool_call_id: 'call_1', + input_text: ':"ls"}', }); - expect(store.getState().items.map((item) => itemId(item))).toEqual(['t1', 't2', 't3']); - expect(store.getState().hasMoreOlder).toBe(true); - // Globals from the older page do not clobber the fresher live state. - expect(store.getState().meta.activity).toBe('idle'); - store.applyPage({ ...emptyPage, items: [turnItem(2)], hasMoreOlder: false }); - expect(store.getState().items.map((item) => itemId(item))).toEqual(['t1', 't2', 't3']); - expect(store.getState().hasMoreOlder).toBe(false); - }); - - it('applies ops through the package reducer and notifies once per batch', () => { - const store = new TranscriptChatStore(); - let notified = 0; - store.subscribe(() => { - notified += 1; + store.applyLive({ + type: 'tool.progress', + ...base, + timestamp: ts(), + tool_call_id: 'call_1', + progress: { kind: 'stdout', text: 'file.txt' }, }); - store.applyOps([ - { op: 'turn.upsert', turn: turnHeader(1, 'running') }, - { op: 'step.upsert', turnId: 't1', step: stepHeader('t1.1', 1) }, - textFrameUpsert('t1', 't1.1', 't1.1.f1', ''), - frameAppend('t1', 't1.1', 't1.1.f1', 0, 'hel'), - frameAppend('t1', 't1.1', 't1.1.f1', 3, 'lo'), + const held = store.getState().entries[0]!.message as ToolCallMessage; + expect(held.input_text).toBe('{"command":"ls"}'); + expect(held.progress).toEqual({ kind: 'stdout', text: 'file.txt' }); + }); + + it('truncates the removed turn subtree on system(undo) and keeps the marker', () => { + const store = makeStore(); + store.applyLive(turnMsg(1)); + store.applyLive(stepMsg('t1.1')); + store.applyLive(assistantMsg('t1.1', 'first')); + store.applyLive(turnMsg(2)); + store.applyLive(stepMsg('t2.1')); + store.applyLive(toolCallMsg('t2.1', 'call_1')); + store.applyLive(undoMsg('sys-undo-1', ['t2'])); + const state = store.getState(); + expect(entryKeys(state.entries)).toEqual([ + 'turn:t1', + 'step:t1.1', + 'assistant:t1.1.a0', + 'system:sys-undo-1', ]); - expect(notified).toBe(1); - const turn = store.getState().items[0]; - expect(turn?.kind).toBe('turn'); - if (turn?.kind === 'turn') { - expect(turn.steps[0]?.frames[0]).toMatchObject({ kind: 'text', text: 'hello' }); - } - }); - - it('absorbs duplicate ops without notifying', () => { - const store = new TranscriptChatStore(); - store.applyOps([{ op: 'turn.upsert', turn: turnHeader(1, 'running') }]); - let notified = 0; - store.subscribe(() => { - notified += 1; + }); + + it('cascades undo to interactions anchored at removed tool calls', () => { + const store = makeStore(); + store.applyLive(turnMsg(1)); + store.applyLive(toolCallMsg('t1.1', 'call_1')); + store.applyLive(interactionMsg('ix-1', 'call_1')); + store.applyLive(interactionMsg('ix-2', 'call_other')); + store.applyLive(undoMsg('sys-undo-1', ['t1'])); + expect([...store.getState().interactions.keys()]).toEqual(['ix-2']); + }); + + it('empties the timeline on system(clear)', () => { + const store = makeStore(); + store.applyLive(turnMsg(1)); + store.applyLive(stepMsg('t1.1')); + store.applyLive(assistantMsg('t1.1', 'gone')); + store.applyLive(systemMsg('clear', 'sys-clear-1', { removed_ids: ['t1', 't1.1', 't1.1.a0'] })); + expect(entryKeys(store.getState().entries)).toEqual(['system:sys-clear-1']); + }); + + it('upserts state entities into their own maps and ignores global messages', () => { + const store = makeStore(); + store.applyLive(interactionMsg('ix-1', 'call_1')); + store.applyLive(taskMsg('task-1')); + store.applyLive({ + type: 'todo', + ...base, + timestamp: ts(), + todo_id: 'todo', + items: [{ title: 'x', status: 'pending' }], }); - store.applyOps([{ op: 'turn.upsert', turn: turnHeader(1, 'running') }]); - expect(notified).toBe(0); - }); - - it('buffered ops converge when flushed onto freshly fetched pages', () => { - const store = new TranscriptChatStore(); - // Simulate: REST page lands AFTER the live ops were produced (buffered). - const buffered: TranscriptOperation[] = [ - { op: 'turn.upsert', turn: turnHeader(1, 'running') }, - { op: 'step.upsert', turnId: 't1', step: stepHeader('t1.1', 1) }, - textFrameUpsert('t1', 't1.1', 't1.1.f1', ''), - frameAppend('t1', 't1.1', 't1.1.f1', 0, 'hello'), - ]; - // The REST snapshot already includes part of the stream ('hel'). - const pageTurn: TranscriptTurn = { - ...turnHeader(1, 'running'), - steps: [ - { - kind: 'step', - stepId: 't1.1', - turnId: 't1', - ordinal: 1, - state: 'running', - frames: [{ kind: 'text', frameId: 't1.1.f1', role: 'assistant', text: 'hel' }], - }, - ], - }; - store.applyPage({ ...emptyPage, items: [pageTurn], hasMoreOlder: false }, { replace: true }); - store.applyOps(buffered); - const turn = store.getState().items[0]; - if (turn?.kind !== 'turn') throw new Error('expected turn'); - expect(turn.steps[0]?.frames[0]).toMatchObject({ kind: 'text', text: 'hello' }); - }); - - it('surfaces append placement gaps through onGap', () => { - const store = new TranscriptChatStore(); - let gaps = 0; - store.onGap = () => { - gaps += 1; - }; - store.applyOps([frameAppend('t1', 't1.1', 't1.1.f1', 0, 'x')]); - expect(gaps).toBe(1); + store.applyLive({ + type: 'session.state', + session_id: 's1', + timestamp: ts(), + status: 'running', + }); + store.applyLive({ + type: 'workspace', + timestamp: ts(), + subtype: 'updated', + workspace: { + id: 'wd_test_0123456789ab', + root: '/tmp', + name: 'tmp', + created_at: new Date(ts()).toISOString(), + last_opened_at: new Date(ts()).toISOString(), + session_count: 1, + }, + }); + const state = store.getState(); + expect(state.interactions.get('ix-1')?.status).toBe('pending'); + expect(state.tasks.get('task-1')?.kind).toBe('shell'); + expect(state.todos.get('todo')?.items).toHaveLength(1); + expect(state.sessionState?.status).toBe('running'); + expect(state.entries).toHaveLength(0); + }); + + it('replace installs the page as the window and keeps entries newer than the page', () => { + const store = makeStore(); + store.applyLive(turnMsg(9, 'running', Date.parse('2026-01-01T00:00:09.000Z'))); + store.applyLive(turnMsg(1, 'completed', Date.parse('2026-01-01T00:00:01.000Z'))); + store.applyHistoryPage( + [turnMsg(1, 'completed', Date.parse('2026-01-01T00:00:01.500Z')), stepMsg('t1.1', 'completed', Date.parse('2026-01-01T00:00:02.000Z'))], + 'replace', + ); + expect(entryKeys(store.getState().entries)).toEqual(['turn:t1', 'step:t1.1', 'turn:t9']); + }); + + it('prepend inserts older pages ahead of the window and dedupes by key', () => { + const store = makeStore(); + store.applyHistoryPage([turnMsg(3)], 'replace'); + store.applyHistoryPage([turnMsg(1), turnMsg(2), turnMsg(3)], 'prepend'); + expect(entryKeys(store.getState().entries)).toEqual(['turn:t1', 'turn:t2', 'turn:t3']); + }); + + it('tail upserts the catch-up slice in page order', () => { + const store = makeStore(); + store.applyHistoryPage([turnMsg(1), stepMsg('t1.1')], 'replace'); + store.applyHistoryPage( + [assistantMsg('t1.1', 'tail'), turnMsg(2), stepMsg('t2.1', 'running')], + 'tail', + ); + expect(entryKeys(store.getState().entries)).toEqual([ + 'turn:t1', + 'step:t1.1', + 'assistant:t1.1.a0', + 'turn:t2', + 'step:t2.1', + ]); + }); + + it('applies a system(undo) inside a history page like a live one', () => { + const store = makeStore(); + store.applyLive(turnMsg(1)); + store.applyLive(turnMsg(2)); + store.applyHistoryPage([undoMsg('sys-undo-1', ['t2'])], 'tail'); + expect(entryKeys(store.getState().entries)).toEqual(['turn:t1', 'system:sys-undo-1']); }); }); +// ---------------------------------------------------------------- helpers + describe('recoverLoadedWindow', () => { - const range = (from: number, to: number): TranscriptTurn[] => - Array.from({ length: to - from + 1 }, (_, i) => turnItem(from + i)); - const pageOf = (items: TranscriptTurn[], hasMoreOlder: boolean): TranscriptPage => ({ - ...emptyPage, - items, - hasMoreOlder, - }); + const pageOf = (items: HistoryMessage[], hasMore: boolean): HistoryMessage[] => items; it('pages backwards until the previous oldest turn is re-covered', async () => { - const store = new TranscriptChatStore(); - // The refresh landed the newest page (t36..t65) while the previously - // loaded window reached t1 — a count-based stop would drop t1..t5. - store.applyPage(pageOf(range(36, 65), true), { replace: true }); - + const store = makeStore(); + store.applyHistoryPage([turnMsg(4), turnMsg(5), turnMsg(6)], 'replace'); + store.setHasMoreOlder(true); const fetched: string[] = []; await recoverLoadedWindow( store, - 't1', + 't2', async (beforeTurn) => { fetched.push(beforeTurn); - return beforeTurn === 't36' ? pageOf(range(6, 35), true) : pageOf(range(1, 5), false); + store.setHasMoreOlder(beforeTurn !== 't2'); + return beforeTurn === 't4' ? [turnMsg(2), turnMsg(3)] : []; }, () => false, ); - - expect(fetched).toEqual(['t36', 't6']); - expect(countTurns(store.getState().items)).toBe(65); - expect(oldestTurnId(store.getState().items)).toBe('t1'); - }); - - it('stops immediately when the window is already covered', async () => { - const store = new TranscriptChatStore(); - store.applyPage(pageOf(range(1, 30), true), { replace: true }); - let calls = 0; - await recoverLoadedWindow( - store, - 't1', - async () => { - calls += 1; - return pageOf([], false); - }, - () => false, - ); - expect(calls).toBe(0); + expect(fetched).toEqual(['t4']); + expect(oldestTurnId(store.getState().entries)).toBe('t2'); + expect(newestTerminalStepId(store.getState().entries)).toBeUndefined(); }); it('stops when there is no older history left, even if the anchor is gone', async () => { - const store = new TranscriptChatStore(); - store.applyPage(pageOf(range(10, 20), true), { replace: true }); + const store = makeStore(); + store.applyHistoryPage([turnMsg(5)], 'replace'); + store.setHasMoreOlder(true); const fetched: string[] = []; await recoverLoadedWindow( store, 't1', async (beforeTurn) => { fetched.push(beforeTurn); + store.setHasMoreOlder(false); return pageOf([], false); }, () => false, ); - // The anchor no longer exists server-side: one no-progress probe, then stop. - expect(fetched).toEqual(['t10']); - expect(countTurns(store.getState().items)).toBe(11); - }); - - it('reports each applied page through onPageApplied', async () => { - const store = new TranscriptChatStore(); - store.applyPage(pageOf(range(36, 65), true), { replace: true }); - const applied: TranscriptPage[] = []; - await recoverLoadedWindow( - store, - 't1', - async (beforeTurn) => - beforeTurn === 't36' ? pageOf(range(6, 35), true) : pageOf(range(1, 5), false), - () => false, - (page) => { - applied.push(page); - }, - ); - expect(applied.map((page) => page.items.map((item) => itemId(item)))).toEqual([ - range(6, 35).map((turn) => turn.turnId), - range(1, 5).map((turn) => turn.turnId), - ]); + expect(fetched).toEqual(['t5']); }); }); -describe('createCoalescedRunner', () => { - const deferred = (): { promise: Promise; resolve: () => void } => { - let resolve!: () => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; - }; +describe('ChatChannel', () => { + function scriptedFetch(script: { noCursor: unknown[]; afterStep?: Record }) { + const calls: string[] = []; + let noCursorIndex = 0; + const fetchImpl = (async (url: string | URL) => { + const text = String(url); + calls.push(text); + const after = /after_step=([^&]+)/.exec(text)?.[1]; + let envelope: unknown; + if (after !== undefined) { + envelope = okEnvelope({ messages: [...(script.afterStep?.[after] ?? [])], has_more: false }); + } else { + envelope = script.noCursor[Math.min(noCursorIndex, script.noCursor.length - 1)]; + noCursorIndex += 1; + } + return { json: async () => envelope }; + }) as unknown as typeof fetch; + return { calls, fetchImpl }; + } - it('runs once per trigger when idle', async () => { - let runs = 0; - const kick = createCoalescedRunner(async () => { - runs += 1; + function makeChannel(fetchImpl: typeof fetch): { channel: ChatChannel; sock: FakeWs } { + FakeWs.reset(); + const channel = new ChatChannel({ + baseUrl: 'http://h:1', + token: 'tok', + sessionId: 's1', + agentId: 'main', + pageSize: 50, + WebSocketImpl: FakeWs, + fetchImpl, + notifyIntervalMs: 0, }); - kick(); - await Promise.resolve(); - kick(); - await Promise.resolve(); - expect(runs).toBe(2); - }); - - it('coalesces triggers during a run into exactly one follow-up', async () => { - let runs = 0; - const gates: Array<() => void> = []; - const kick = createCoalescedRunner(async () => { - runs += 1; - const gate = deferred(); - gates.push(gate.resolve); - await gate.promise; + return { channel, sock: FakeWs.instances[0]! }; + } + + it('serializes the initial refresh with the ack catch-up behind one queue', async () => { + const newest = okEnvelope({ messages: [turnMsg(1), stepMsg('t1.1')], has_more: false }); + const { calls, fetchImpl } = scriptedFetch({ noCursor: [newest] }); + let releaseFirst: () => void = () => {}; + const gate = new Promise((resolve) => { + releaseFirst = resolve; }); - kick(); - kick(); - kick(); - expect(runs).toBe(1); - gates[0]?.(); + let first = true; + const gatedFetch = (async (url: string | URL, init?: RequestInit) => { + if (first) { + first = false; + await gate; + } + return fetchImpl(url, init); + }) as unknown as typeof fetch; + const { channel, sock } = makeChannel(gatedFetch); + channel.start(); + sock.open(); + sock.hello(); + sock.serverFrame({ type: 'ack', id: 1, code: 0 }); + releaseFirst(); await vi.waitFor(() => { - expect(runs).toBe(2); + expect(calls).toHaveLength(3); }); - gates[1]?.(); + const restEntries = channel.trail.getEntries().filter((e) => e.kind === 'rest'); + expect(restEntries.filter((e) => e.mode === 'replace')).toHaveLength(1); + expect(channel.trail.getEntries().some((e) => e.kind === 'event' && e.event === 'catchup-refresh')).toBe(false); + expect(calls.filter((url) => !url.includes('after_step='))).toHaveLength(2); + expect(calls[1]).toContain('after_step=t1.1'); + expect(newestTerminalStepId(channel.store.getState().entries)).toBe('t1.1'); + channel.close(); + }); + + it('probes the newest page for the anchor step or turn before falling back to a refresh', async () => { + const first = okEnvelope({ messages: [turnMsg(1), stepMsg('t1.1')], has_more: false }); + const probeWithTurn = okEnvelope({ messages: [systemMsg('notice', 'sys_n1'), turnMsg(1)], has_more: false }); + const alive = scriptedFetch({ noCursor: [first, probeWithTurn] }); + const aliveChannel = makeChannel(alive.fetchImpl); + aliveChannel.channel.start(); + aliveChannel.sock.open(); + aliveChannel.sock.hello(); + aliveChannel.sock.serverFrame({ type: 'ack', id: 1, code: 0 }); await vi.waitFor(() => { - expect(gates.length).toBe(2); + expect(aliveChannel.channel.store.getState().entries.length).toBeGreaterThan(0); }); - // No third run: the two mid-run triggers were coalesced into one. - }); - - it('queues again when a trigger lands during the follow-up run', async () => { - let runs = 0; - const gates: Array<() => void> = []; - const kick = createCoalescedRunner(async () => { - runs += 1; - const gate = deferred(); - gates.push(gate.resolve); - await gate.promise; + await vi.waitFor(() => { + expect(alive.calls).toHaveLength(3); }); - kick(); - kick(); - gates[0]?.(); + expect( + aliveChannel.channel.trail.getEntries().some((e) => e.kind === 'event' && e.event === 'catchup-refresh'), + ).toBe(false); + expect(aliveChannel.channel.trail.getEntries().filter((e) => e.kind === 'rest' && e.mode === 'replace')).toHaveLength(1); + aliveChannel.channel.close(); + + const movedOn = okEnvelope({ messages: [turnMsg(2), stepMsg('t2.1')], has_more: false }); + const gone = scriptedFetch({ noCursor: [first, movedOn] }); + const goneChannel = makeChannel(gone.fetchImpl); + goneChannel.channel.start(); + goneChannel.sock.open(); + goneChannel.sock.hello(); + goneChannel.sock.serverFrame({ type: 'ack', id: 1, code: 0 }); await vi.waitFor(() => { - expect(runs).toBe(2); + expect( + goneChannel.channel.trail.getEntries().some((e) => e.kind === 'event' && e.event === 'catchup-refresh'), + ).toBe(true); }); - kick(); - gates[1]?.(); await vi.waitFor(() => { - expect(runs).toBe(3); + expect(newestTerminalStepId(goneChannel.channel.store.getState().entries)).toBe('t2.1'); }); - gates[2]?.(); + goneChannel.channel.close(); + }); +}); + +// ---------------------------------------------------------------- plan + +describe('projectPlans', () => { + const planCall = (id: string, overrides: Partial = {}): ToolCallMessage => + toolCallMsg('t1.1', id, { name: 'ExitPlanMode', status: 'done', ...overrides }); + + it('derives plan content and review from the linked approval interaction', () => { + const messages: HistoryMessage[] = [ + turnMsg(1), + planCall('call_plan', { approval_id: 'ix-1' }), + { + type: 'interaction', + ...base, + timestamp: ts(), + interaction_id: 'ix-1', + kind: 'approval', + status: 'approved', + tool_call_id: 'call_plan', + request: { + tool_name: 'ExitPlanMode', + action: 'review', + tool_input_display: { + kind: 'plan_review', + plan: '# The Plan\n\nDo the thing.', + path: '/tmp/plans/foo.md', + options: [{ label: 'Approach A', description: 'fast' }], + }, + }, + response: { decision: 'approved', selected_label: 'Approach A', feedback: 'looks good' }, + }, + ]; + const plans = projectPlans(messages); + expect(plans).toEqual([ + { + toolCallId: 'call_plan', + turnId: 't1', + source: 'interaction', + plan: '# The Plan\n\nDo the thing.', + path: '/tmp/plans/foo.md', + options: [{ label: 'Approach A', description: 'fast' }], + review: { state: 'approved', selectedOption: 'Approach A', feedback: 'looks good' }, + }, + ]); + }); + + it('falls back to the tool call display, then to the output body', () => { + const fromDisplay = projectPlans([ + planCall('call_display', { + display: { kind: 'plan_review', plan: '# Draft', path: '/tmp/draft.md' }, + }), + ]); + expect(fromDisplay[0]).toMatchObject({ source: 'display', plan: '# Draft', path: '/tmp/draft.md' }); + const fromOutput = projectPlans([ + planCall('call_output', { + output: 'Plan saved to: /tmp/out.md\n## Approved Plan:\n# Final', + }), + ]); + expect(fromOutput[0]).toMatchObject({ source: 'output', plan: '# Final', path: '/tmp/out.md' }); + }); + + it('filters by tool_call_id and ignores non-ExitPlanMode calls', () => { + const messages: HistoryMessage[] = [ + planCall('call_a', { display: { kind: 'plan_review', plan: '# A' } }), + toolCallMsg('t1.1', 'call_bash', { name: 'Bash', status: 'done' }), + planCall('call_b', { display: { kind: 'plan_review', plan: '# B' } }), + ]; + expect(projectPlans(messages, 'call_b').map((p) => p.toolCallId)).toEqual(['call_b']); + expect(projectPlans(messages).map((p) => p.toolCallId)).toEqual(['call_a', 'call_b']); }); }); diff --git a/apps/kimi-inspect/src/transcript/ws.ts b/apps/kimi-inspect/src/transcript/ws.ts index 8e5d160e73e..d421d40bd91 100644 --- a/apps/kimi-inspect/src/transcript/ws.ts +++ b/apps/kimi-inspect/src/transcript/ws.ts @@ -1,106 +1,97 @@ /** - * Minimal `/api/v1/ws` client for the transcript stream — **block grade**. + * Minimal `/api/v3/ws` client for the message protocol. * - * The socket is used exclusively as an incremental channel, at the cheapest - * grade that keeps the live view correct: 'block' drops the per-token - * `append` frames (the bulk of transcript traffic) and still receives the - * whole-state frame upserts at every flush point, so content converges - * without a REST round-trip. After the - * upgrade, the client sends `client_hello` with the session in - * `subscriptions`, then a `subscribe_v2` frame carrying the opt-in - * `transcript` grade map (plus the `transcript_since` cursor when a - * watermark is known), and forwards every `transcript.ops` frame to the - * consumer. Full state never comes from here: - * `transcript.reset` snapshots are ignored by the store (they are surfaced - * through the optional `onReset` handler for observers like the audit panel), - * because complete data (initial load and any refresh) is read back from the - * REST transcript API, paged from the tail backwards. + * Handshake per the protocol contract: the server sends `hello` right after + * the upgrade, the client answers with `subscribe` (`{id, session_id, + * agent_ids?, omit?}`), the server replies with `ack` (matched by `id`) and + * then streams the recovery payload followed by live traffic — one ordered + * session sequence, no cursors anywhere. Heartbeat is the WS protocol-level + * ping/pong, handled by the WebSocket implementation itself. * - * Loss signals are surfaced, not repaired locally — transcript frames are - * volatile by design (never journaled), so the consumer answers them with a - * REST refresh: `resync_required` → `onResyncRequired`, and the - * `subscribe_v2` ack after every established socket → `onReconnected` (the - * server attaches the stream only after processing `subscribe_v2`; ops - * emitted between the REST page load and that point are missed). + * Every data frame is validated against the shared + * `serverMessageSchema`; control frames (`hello` / `ack` / `error`) are + * handled here, everything else is forwarded through `onMessage`. The union + * is open: a frame whose `type` is not in the current schema is a future + * message type and is ignored silently; a frame that names a known type but + * fails validation is a server bug and surfaces via `onInvalidFrame`. * - * The bearer token is presented at the upgrade through the - * `kimi-code.bearer.` subprotocol (the only credential channel a - * browser WebSocket has). + * A drop is answered with a backoff reconnect and a fresh subscribe — the + * recovery payload is idempotent, so the consumer's only job on `onAck` is + * to run its REST tail catch-up. The bearer token rides the + * `kimi-code.bearer.` subprotocol at the upgrade (the only + * credential channel a browser WebSocket has). */ -import { - transcriptOpsEventSchema, - transcriptResetEventSchema, - type AgentTranscriptSnapshot, - type TranscriptOperation, -} from '@moonshot-ai/transcript'; +import { serverMessageSchema, type ServerMessage } from '@moonshot-ai/kap-server/protocol'; import type { WsLike, WsLikeCtor } from '../channel/wsLike'; -/** Envelope/payload metadata carried alongside a transcript frame (for auditing + seq tracking). */ -export interface TranscriptFrameMeta { - /** Envelope `timestamp` (server send time, ISO); absent on legacy servers. */ - readonly at?: string | undefined; - /** Op-batch sequence number (payload `seq`); absent on legacy servers. */ - readonly seq?: number | undefined; -} +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + +const KNOWN_MESSAGE_TYPES: ReadonlySet = new Set([ + 'turn', + 'step', + 'user', + 'assistant', + 'assistant.delta', + 'thinking', + 'thinking.delta', + 'tool_call', + 'tool_call.delta', + 'tool.progress', + 'system', + 'interaction', + 'task', + 'todo', + 'session.state', + 'session', + 'workspace', + 'config', + 'config.warning', + 'model_catalog', + 'plugin', + 'capability', + 'hello', + 'ack', + 'error', +]); -export interface TranscriptWsHandlers { - /** Incremental L2 op batch for the agent (the only data frame consumed). */ - onOps: (agentId: string, ops: readonly TranscriptOperation[], meta?: TranscriptFrameMeta) => void; - /** - * Baseline snapshot frame. The chat consumer deliberately ignores these - * (full state is REST-sourced) — the handler exists for observers such as - * the audit panel that want to record every frame on the wire. - */ - onReset?: ( - agentId: string, - snapshot: AgentTranscriptSnapshot, - hasMoreOlder: boolean, - meta?: TranscriptFrameMeta, - ) => void; - /** Server signalled desync for our session — consumer should REST-refresh. */ - onResyncRequired: () => void; - /** Socket re-established after a drop — volatile ops were missed meanwhile. */ - onReconnected: () => void; +export interface ChatWsHandlers { + /** Any validated non-control server message (entity, delta, state, global). */ + onMessage: (message: ServerMessage) => void; + /** The subscribe ack (code 0 = subscribed) — fires on every (re)subscribe. */ + onAck: (code: number, msg?: string) => void; + /** Protocol-level `error` frame (auth failure, unknown frame, slow consumer). */ + onProtocolError: (code: number, msg: string) => void; + /** A frame naming a KNOWN type failed schema validation (server bug). */ + onInvalidFrame?: (raw: unknown) => void; + /** The socket dropped and a reconnect attempt is scheduled. */ + onReconnectScheduled?: (attempt: number) => void; } -export interface TranscriptWsOptions { - /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v1/ws` URL. */ +export interface ChatWsOptions { + /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v3/ws` URL. */ readonly url: string; - readonly token?: string | undefined; + readonly token?: string; readonly sessionId: string; - readonly agentId: string; - readonly handlers: TranscriptWsHandlers; - /** - * Returns the caller's current op-batch watermark at (re)subscribe time; - * when defined it is sent as the `transcript_since` cursor so a sequenced - * server replays missed batches instead of sending a baseline reset. - */ - readonly getSince?: (() => number | undefined) | undefined; + /** Agents to subscribe; defaults to all agents of the session when empty. */ + readonly agentIds?: readonly string[]; + /** Message types to exclude from the subscription (exact `type` names). */ + readonly omit?: readonly string[]; + readonly handlers: ChatWsHandlers; /** WebSocket implementation; defaults to the global `WebSocket`. */ readonly WebSocketImpl?: WsLikeCtor; /** Base delay (ms) for the reconnect backoff. Default `500`. */ readonly reconnectDelayMs?: number; } -interface ServerFrame { - readonly type: string; - readonly id?: string; - readonly code?: number; - readonly timestamp?: string; - readonly payload?: unknown; -} - -const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; - -export class TranscriptWs { +export class ChatWs { private readonly wsUrl: string; private readonly token?: string; private readonly sessionId: string; - private readonly agentId: string; - private readonly handlers: TranscriptWsHandlers; - private readonly getSince?: (() => number | undefined) | undefined; + private readonly agentIds?: readonly string[]; + private readonly omit?: readonly string[]; + private readonly handlers: ChatWsHandlers; private readonly WsCtor: WsLikeCtor; private readonly reconnectDelayMs: number; @@ -108,17 +99,15 @@ export class TranscriptWs { private manualClose = false; private reconnectAttempt = 0; private reconnectTimer: ReturnType | undefined; - private helloId: string | undefined; - private subscribeV2Id: string | undefined; - private subscribeV2Acked = false; + private subscribeId = 0; - constructor(opts: TranscriptWsOptions) { - this.wsUrl = toWsUrl(opts.url); + constructor(opts: ChatWsOptions) { + this.wsUrl = toWsV3Url(opts.url); this.token = opts.token; this.sessionId = opts.sessionId; - this.agentId = opts.agentId; + this.agentIds = opts.agentIds; + this.omit = opts.omit; this.handlers = opts.handlers; - this.getSince = opts.getSince; const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined); if (ctor === undefined) { throw new Error('no WebSocket implementation available; pass WebSocketImpl'); @@ -140,6 +129,25 @@ export class TranscriptWs { ws?.close(); } + /** Force a reconnect (debug/testing): drop the socket and re-subscribe after `delayMs`. */ + reconnect(delayMs = 0): void { + if (this.manualClose) return; + if (this.reconnectTimer !== undefined) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + const ws = this.ws; + this.ws = undefined; + ws?.close(); + this.reconnectAttempt += 1; + this.handlers.onReconnectScheduled?.(this.reconnectAttempt); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delayMs); + this.reconnectTimer.unref?.(); + } + private connect(): void { const protocols = this.token !== undefined && this.token.length > 0 @@ -155,108 +163,68 @@ export class TranscriptWs { this.ws = ws; ws.addEventListener('open', () => { this.reconnectAttempt = 0; - this.helloId = `kimi-inspect-${Date.now().toString(36)}`; - this.subscribeV2Id = `${this.helloId}-sub`; - this.subscribeV2Acked = false; - const since = this.getSince?.(); - this.send({ - type: 'client_hello', - id: this.helloId, - payload: { - client_id: 'kimi-inspect', - subscriptions: [this.sessionId], - }, - }); - // Transcript grades ride only `subscribe_v2` — sent right after the - // hello on the same socket, so the server processes them in order. - this.send({ - type: 'subscribe_v2', - id: this.subscribeV2Id, - payload: { - session_id: this.sessionId, - transcript: { [this.agentId]: 'block' }, - transcript_since: since !== undefined ? { [this.agentId]: since } : undefined, - }, - }); - // The reconcile fires on the subscribe_v2 ACK (see onMessage) — the - // server attaches the transcript stream only after processing - // subscribe_v2, so refreshing at open could finish before the - // subscription is active and still miss the ops in between. }); ws.addEventListener('message', (event: { data: unknown }) => { this.onMessage(event.data); }); ws.addEventListener('close', () => { - // Stale socket (a manual close already cleared `this.ws`). if (this.ws !== ws) return; this.ws = undefined; if (!this.manualClose) this.scheduleReconnect(); }); - ws.addEventListener('error', () => { - // The 'close' event always follows 'error'; reconnect logic lives there. - }); + ws.addEventListener('error', () => {}); } private onMessage(raw: unknown): void { - let frame: ServerFrame; + let frame: unknown; try { - frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame; + frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)); } catch { + this.handlers.onInvalidFrame?.(raw); return; } - switch (frame.type) { - case 'ack': { - // The subscribe_v2 ack: the server has attached the transcript stream - // by now — reconcile once per socket (ops emitted between the REST - // page load and this point are missed; the consumer refreshes). - if (!this.subscribeV2Acked && frame.id !== undefined && frame.id === this.subscribeV2Id) { - this.subscribeV2Acked = true; - this.handlers.onReconnected(); - } - return; + const parsed = serverMessageSchema.safeParse(frame); + if (!parsed.success) { + const type = (frame as { readonly type?: unknown } | null)?.type; + if (typeof type !== 'string' || KNOWN_MESSAGE_TYPES.has(type)) { + this.handlers.onInvalidFrame?.(frame); } - case 'transcript.ops': { - const parsed = transcriptOpsEventSchema.safeParse(frame.payload); - if (!parsed.success) return; - this.handlers.onOps(parsed.data.agent_id, parsed.data.ops, { - at: frame.timestamp, - seq: parsed.data.seq, + return; + } + const message = parsed.data; + switch (message.type) { + case 'hello': { + this.subscribeId += 1; + this.send({ + type: 'subscribe', + id: this.subscribeId, + session_id: this.sessionId, + agent_ids: this.agentIds !== undefined && this.agentIds.length > 0 ? [...this.agentIds] : undefined, + omit: this.omit !== undefined && this.omit.length > 0 ? [...this.omit] : undefined, }); return; } - case 'transcript.reset': { - // Snapshots are deliberately ignored by the chat store: full state is - // REST-sourced. Surface them to optional observers (audit panel). - if (this.handlers.onReset === undefined) return; - const parsed = transcriptResetEventSchema.safeParse(frame.payload); - if (!parsed.success) return; - this.handlers.onReset( - parsed.data.agent_id, - parsed.data.snapshot, - parsed.data.has_more_older, - { at: frame.timestamp, seq: parsed.data.seq }, - ); + case 'ack': { + if (message.id === this.subscribeId) { + this.handlers.onAck(message.code, message.msg); + } return; } - case 'ping': { - const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce; - this.send({ type: 'pong', payload: { nonce } }); + case 'error': { + this.handlers.onProtocolError(message.code, message.msg); return; } - case 'resync_required': { - const sessionId = (frame.payload as { session_id?: unknown } | undefined)?.session_id; - if (sessionId === this.sessionId) this.handlers.onResyncRequired(); + default: { + this.handlers.onMessage(message); return; } - default: - // server_hello / ack / legacy session events — not consumed here. - return; } } private scheduleReconnect(): void { if (this.manualClose) return; this.reconnectAttempt += 1; + this.handlers.onReconnectScheduled?.(this.reconnectAttempt); const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; @@ -271,21 +239,20 @@ export class TranscriptWs { try { ws.send(JSON.stringify(frame)); } catch { - // best-effort; the close handler handles teardown } } } -/** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ -function toWsUrl(base: string): string { +/** Derive the `/api/v3/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ +function toWsV3Url(base: string): string { const url = new URL(base); if (url.protocol === 'http:') url.protocol = 'ws:'; else if (url.protocol === 'https:') url.protocol = 'wss:'; if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { throw new Error(`unsupported URL scheme for WS transport: ${base}`); } - if (!url.pathname.endsWith('/api/v1/ws')) { - url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`; + if (!url.pathname.endsWith('/api/v3/ws')) { + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v3/ws`; } url.search = ''; url.hash = ''; diff --git a/apps/kimi-inspect/src/ui.tsx b/apps/kimi-inspect/src/ui.tsx index d132795472e..11204f9d312 100644 --- a/apps/kimi-inspect/src/ui.tsx +++ b/apps/kimi-inspect/src/ui.tsx @@ -97,210 +97,3 @@ export function ErrorLine({ error }: { error: unknown }) { const msg = errorMessage(error); return
{msg}
; } - -// --------------------------------------------------------------------------- -// JSON tree (selectable) — the left column of the model inspect panel. -// --------------------------------------------------------------------------- - -export function JsonTree({ - data, - selectedPath, - onSelect, - defaultDepth = 2, - rowClassName, -}: { - readonly data: unknown; - readonly selectedPath?: string; - readonly onSelect: (path: string) => void; - readonly defaultDepth?: number; - /** Per-row styling hook (e.g. provenance colors); receives the node's dot path. */ - readonly rowClassName?: (path: string) => string | undefined; -}) { - return ( -
- -
- ); -} - -function TreeNode({ - name, - value, - path, - depth, - defaultDepth, - selectedPath, - onSelect, - rowClassName, -}: { - readonly name?: string; - readonly value: unknown; - readonly path: string; - readonly depth: number; - readonly defaultDepth: number; - readonly selectedPath?: string; - readonly onSelect: (path: string) => void; - readonly rowClassName?: (path: string) => string | undefined; -}) { - const [open, setOpen] = useState(depth < defaultDepth); - const expandable = value !== null && typeof value === 'object'; - const pathClass = rowClassName?.(path); - - // The root renders its entries directly (no row of its own). - if (path === '' && name === undefined && expandable) { - const entries = Array.isArray(value) - ? value.map((item, index) => [String(index), item] as const) - : Object.entries(value); - return ( - <> - {entries.map(([key, item]) => ( - - ))} - - ); - } - - if (!expandable) { - return ( - - {name !== undefined ? ( - {name}: - ) : null} - - - ); - } - - const isArray = Array.isArray(value); - // Undefined is not JSON: records carry optional keys explicitly set to - // undefined — skip them entirely instead of rendering source-less noise. - const entries = isArray - ? value.map((item, index) => [String(index), item] as const) - : Object.entries(value as Record).filter(([, item]) => item !== undefined); - const [openBrace, closeBrace] = isArray ? ['[', ']'] : ['{', '}']; - return ( -
- - { - e.stopPropagation(); - setOpen((v) => !v); - }} - > - {open ? '▾ ' : '▸ '} - - {name !== undefined ? ( - {name}: - ) : null} - { - e.stopPropagation(); - setOpen((v) => !v); - }} - > - {open ? openBrace : `${openBrace} …${entries.length} ${closeBrace}`} - - - {open - ? entries.map(([key, item]) => ( - - )) - : null} -
- ); -} - -function TreeRow({ - path, - depth, - selectedPath, - onSelect, - rowClass, - children, -}: { - readonly path: string; - readonly depth: number; - readonly selectedPath?: string; - readonly onSelect: (path: string) => void; - readonly rowClass?: string; - readonly children: React.ReactNode; -}) { - const selected = path !== '' && path === selectedPath; - return ( -
{ - onSelect(path); - }} - title={path} - > - {children} -
- ); -} - -function LeafValue({ value, className }: { readonly value: unknown; readonly className?: string }) { - if (value === null) return null; - if (value === undefined) { - return undefined; - } - if (typeof value === 'string') { - const shown = value.length > 80 ? `${value.slice(0, 80)}…` : value; - return "{shown}"; - } - if (typeof value === 'number') { - return {String(value)}; - } - if (typeof value === 'boolean') { - return {String(value)}; - } - return ( - {JSON.stringify(value) ?? 'unknown'} - ); -} diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index a91e22d3abe..29f08109e6f 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -4,6 +4,10 @@ MCP tool results can include text (`content`) and structured data (`structuredContent`). Kimi Code CLI makes both available to the agent and omits the structured copy only when it can confirm that a text block already contains the same complete JSON value. Text summaries and media do not replace structured records. +Kimi Code CLI preserves embedded MCP attachments that cannot be delivered directly because of format or size limits. Embedded images, audio, and video are saved even when they can be delivered unchanged, because provider conversion or later history reduction may omit them. Session-attachment readers remain available without workspace filesystem access when the model supports the corresponding content. Originals are retained in the session's media storage instead of an evictable image cache. Saved originals, including images preserved during compression, have absolute paths and stable `kimi-file://` references. Pass a reference as the `path` to `Read` or `ReadMediaFile`; bytes are read from the current session's storage even when the workspace runtime cannot access it. Pagination keeps the reference, including after a fork. For binary formats that `Read` cannot open, its error includes a server-local path when available; an external converter must have access to that filesystem. Text attachments such as CSV, HTML, JSON, and plain SVG use readable extensions. + +Attachment paths and compression details share the tool-output budget. Large lists are saved to a text file, with a short pointer that remains visible when accompanying text is shortened; the agent can pass the list’s `kimi-file://` reference to `Read` and page through it. Canceling the tool stops subsequent attachment processing and signals active writes. If decoding or saving fails, the result explicitly reports that the original could not be preserved while retaining other usable output. Resource links are not automatically downloaded. + ## Connection Methods Kimi Code CLI supports three MCP server connection methods: diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md index f54c531bb3c..070495ad5ac 100644 --- a/docs/en/guides/goals.md +++ b/docs/en/guides/goals.md @@ -106,6 +106,8 @@ A goal can stop in three ways: Write stop conditions into the objective. `/goal` does not have a separate stop-limit flag. +Time budgets count only while the goal is active and its session is open. Closing the session saves the elapsed time and pauses the goal. After reopening the session, use `/goal resume` to continue with the remaining budget; time spent closed or paused does not count. + ## Manage goals in the web UI The web UI shows the current goal in a strip below the conversation. Select the strip to expand or collapse its details. When a token budget is configured, the header shows its progress; goals without a token budget do not show a progress bar. diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index cd17ae92f16..1372c5113ed 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -126,6 +126,8 @@ The input box remains usable while the agent is thinking or calling tools, and s - **`Esc` / `Ctrl-C`**: interrupt the current turn - **`Ctrl-O`**: globally toggle the collapsed/expanded state of tool output and compaction summaries +When the agent is waiting for background tasks through `WaitFor`, pressing `Ctrl-S` ends that wait early. Background tasks keep running and existing tool results are preserved. If other foreground tools remain in the same batch, the agent processes your message after they return. + ## External editor Press `Ctrl-G` to send the current input content to an external editor. When you save and close, the text is written back into the input box; if you close without saving, the original content is preserved. This is handy when you need to enter large blocks of text or content with complex formatting. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 915155bf7e3..280e8fe9575 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -266,10 +266,10 @@ For full migration instructions, see [Migrating from kimi-cli](../guides/migrati Immediately check for the latest version and display an update prompt; exits after you make a selection. `kimi update` is an alias for this command. ```sh -kimi upgrade +kimi upgrade [-y] ``` -For global npm, pnpm, yarn, and bun installations, `kimi upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. For native installations (including Windows), it downloads and verifies the new binary in the foreground and swaps it in on the next start. When the current installation method cannot be upgraded automatically, the manual update command is printed instead. +For global npm, pnpm, yarn, and bun installations, `kimi upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. For native installations (including Windows), it downloads and verifies the new binary in the foreground and swaps it in on the next start. When the current installation method cannot be upgraded automatically, the manual update command is printed instead. Pass `-y, --yes` to skip the confirmation prompt and install the update directly. ### `kimi vis` diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index df56d7cb15a..d2d16365c9d 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -131,7 +131,7 @@ Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuest **`TaskStop`** accepts a `task_id` and optional `reason` (defaults to `Stopped by TaskStop`). Safe to call on tasks that are already in a terminal state. -**`WaitFor`** suspends the current turn until a background task finishes or the timeout elapses. Parameters: `timeout` (required, in seconds, max 600) and optional `task_id`. Without `task_id`, the wait ends as soon as any background task that was running at call time finishes; when no background tasks are running, it returns immediately. A timeout is not an error — the result lists the tasks still running, and the Agent can wait again or do other work meanwhile. A task whose result was reported by `WaitFor` does not also produce an automatic completion notification. +**`WaitFor`** suspends the current turn until a background task finishes, the timeout elapses, or a steer message arrives. Parameters: `timeout` (required, in seconds, max 600) and optional `task_id`. Without `task_id`, the wait ends as soon as any background task that was running at call time finishes; when no background tasks are running, it returns immediately. A timeout is not an error — the result lists the tasks still running, and the Agent can wait again or do other work meanwhile. Steering (`Ctrl-S` in the terminal) ends the wait early; background tasks keep running and still notify the agent on completion. A task whose result was reported by `WaitFor` does not also produce an automatic completion notification. ## Scheduled Tasks diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 4da719f1f36..adccb02795b 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,28 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.42.0 (2026-09-09) + +### Features + +- Remote Control is now always on; the experimental `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL` flag has been removed. See [Remote Control](https://moonshotai.github.io/kimi-code/guides/remote-control.html) for details. +- web: Support permanently deleting sessions from the session row context menu, with a confirmation prompt. +- Add read-only tools to the `/btw` side agent. +- web: Preview images and videos in a reorderable media rail in the composer, mention them in the text on demand, and keep the previews after queueing and sending. +- Accept HEIC, HEIF, and BMP images in prompt attachments and `ReadMediaFile` when the model is served by Kimi. + +### Polish + +- Collapse finished tool calls in the transcript to a header plus one marked outcome row: short output is shown whole, hidden output is counted (`N more lines`, `+N more`) and revealed by `Ctrl-O`, which the footer advertises while it is available. +- Upgrade the default thinking effort to the recommended level for eligible users. +- The subagent model pool (`[secondary_model]`) is now always on; the experimental secondary-model flag and the `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` opt-out have been removed. +- Add configurable character limits and resumable long-line file reads without repeated output truncation; see [`read`](https://moonshotai.github.io/kimi-code/configuration/config-files.html#read) for details. +- The minidb session-index read model and global search worker are now always on; the experimental flags have been replaced by the `[database]` config section and the `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` / `KIMI_CODE_SEARCH_WORKER` env vars; see [`database`](https://moonshotai.github.io/kimi-code/configuration/config-files.html#database) for details. + +### Bug Fixes + +- Fix several known issues and make various refinements. See the [changelog on GitHub](https://github.com/MoonshotAI/kimi-code/blob/main/apps/kimi-code/CHANGELOG.md) for more technical entries. + ## 0.41.0 (2026-09-04) ### Features diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index 52ac7cf8ccc..bd1faf85440 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -4,6 +4,10 @@ MCP 工具结果可以包含文本(`content`)和结构化数据(`structuredContent`)。Kimi Code CLI 会将两者提供给 Agent,只有能够确认某个文本块已包含同一份完整 JSON 值时,才省略重复的结构化内容。文本摘要和媒体不会替代结构化记录。 +Kimi Code CLI 会保留因格式或大小限制而无法直接交付的内嵌 MCP 附件。内嵌图片、音频和视频即使能够原样交付也会保存,因为后续供应商协议转换或历史精简可能省略它们。模型支持相应内容时,即使工作区文件系统不可用,也仍可读取会话附件。原件随会话保存在媒体存储中,不会被图片缓存淘汰。保存的原件(包括图片压缩前的原图)均提供绝对路径和稳定的 `kimi-file://` 引用。将引用作为 `path` 传给 `Read` 或 `ReadMediaFile`,即使工作区 runtime 无法访问会话存储,也能直接从当前会话存储读取字节。分页续读会保留该引用,包括 fork 后的会话。对于 `Read` 无法打开的二进制格式,错误信息会在可用时提供服务端本地路径;外部转换工具必须能够访问该文件系统。CSV、HTML、JSON 和普通 SVG 等文本附件使用可读取的扩展名。 + +附件路径和压缩说明共用工具输出预算。较长的清单会保存为文本文件,结果中保留简短指针,即使伴随的文本被截短,该指针仍然可见;Agent 可将清单的 `kimi-file://` 引用传给 `Read`,分页读取完整内容。取消工具调用会停止后续附件处理,并通知正在进行的写入操作。如果解码或保存失败,结果会明确说明原件未能保留,并保留其他可用输出。资源链接不会被自动下载。 + ## 接入方式 Kimi Code CLI 支持三种 MCP server 接入方式: diff --git a/docs/zh/guides/goals.md b/docs/zh/guides/goals.md index 8600e266f1f..e1107d73ded 100644 --- a/docs/zh/guides/goals.md +++ b/docs/zh/guides/goals.md @@ -106,6 +106,8 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 停止条件需要写在目标本身里。`/goal` 没有单独用于描述停止限制的语法。 +时间预算只在目标处于活跃状态且会话保持打开时计时。关闭会话会保存累计用时并暂停目标。重新打开会话后,使用 `/goal resume` 按剩余预算继续;会话关闭或目标暂停期间不计时。 + ## 在 Web 界面中管理目标 Web 界面会在对话下方显示当前目标条。点击目标条可以展开或收起详细信息。配置 token 预算时,标题栏会显示预算进度;没有配置 token 预算的目标不会显示进度条。 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 75b56d3026a..28c183f5e8a 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -126,6 +126,8 @@ Agent 思考或调用工具时,输入框仍然可用,支持以下额外操 - **`Esc` / `Ctrl-C`**:中断当前轮次 - **`Ctrl-O`**:全局切换工具输出和压缩摘要的折叠状态 +Agent 正通过 `WaitFor` 等待后台任务时,按 `Ctrl-S` 会提前结束本次等待。后台任务继续运行,已有工具结果保留;如果同批还有其他前台工具,Agent 会在它们返回后处理新消息。 + ## 外部编辑器 按 `Ctrl-G` 把当前输入内容发给外部编辑器,保存后回填到输入框,不保存则保持原样。适合需要输入大段文本或带格式内容的场景。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index ce1caba10ad..5b9483918a1 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -266,10 +266,10 @@ kimi migrate 立即检查最新版本并展示更新提示,选择操作后退出。也可以使用别名 `kimi update`。 ```sh -kimi upgrade +kimi upgrade [-y] ``` -对全局 npm、pnpm、yarn、bun 安装,`kimi upgrade` 会展示更新选项;选择 `Install update now` 后运行对应的前台安装命令。对 native 安装(含 Windows),会在前台下载并校验新二进制,并在下次启动时替换生效。当前安装方式无法自动升级时,改为打印手动更新命令。 +对全局 npm、pnpm、yarn、bun 安装,`kimi upgrade` 会展示更新选项;选择 `Install update now` 后运行对应的前台安装命令。对 native 安装(含 Windows),会在前台下载并校验新二进制,并在下次启动时替换生效。当前安装方式无法自动升级时,改为打印手动更新命令。传入 `-y, --yes` 可跳过确认提示,直接安装更新。 ### `kimi vis` diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index f7faf9cda5c..2fc2d967b29 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -131,7 +131,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`TaskStop`** 接受 `task_id` 和可选的 `reason`(默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用。 -**`WaitFor`** 把当前轮次挂起,直到后台任务结束或超时。参数:`timeout`(必填,单位秒,上限 600)和可选的 `task_id`。不传 `task_id` 时,调用时刻运行中的任意一个后台任务结束即返回;当前没有运行中的后台任务时立即返回。超时不是错误——结果会列出仍在运行的任务,Agent 可以再次等待,也可以先处理其他工作。已通过 `WaitFor` 汇报结果的任务不会再推送自动完成通知。 +**`WaitFor`** 把当前轮次挂起,直到后台任务结束、超时或收到 steer 消息。参数:`timeout`(必填,单位秒,上限 600)和可选的 `task_id`。不传 `task_id` 时,调用时刻运行中的任意一个后台任务结束即返回;当前没有运行中的后台任务时立即返回。超时不是错误——结果会列出仍在运行的任务,Agent 可以再次等待,也可以先处理其他工作。Steer(终端中按 `Ctrl-S`)会提前结束本次等待,后台任务继续运行,完成后仍会自动通知。已通过 `WaitFor` 汇报结果的任务不会再推送自动完成通知。 ## 定时任务 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index e662e22f1bd..7f8cea8909c 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,28 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.42.0(2026-09-09) + +### 新功能 + +- Remote Control 由实验性转为正式,无需再设置 `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL` 实验开关。详见 [Remote Control](https://moonshotai.github.io/kimi-code/zh/guides/remote-control.html)。 +- Web 版支持从会话行的右键菜单永久删除会话,删除前会要求确认。 +- `/btw` 侧边聊天的 subagent 新增只读工具。 +- Web 版输入框新增可排序的媒体预览栏,可在文本中按需引用图片和视频,排队与发送后预览仍然保留。 +- 模型由 Kimi 提供时,支持在提示词附件与 `ReadMediaFile` 中使用 HEIC、HEIF 和 BMP 图片。 + +### 优化 + +- 消息记录中已完成的工具调用现折叠为标题加一行结果摘要:短输出完整展示,隐藏内容以 `N more lines`、`+N more` 计数并按 `Ctrl-O` 展开,页脚会在可用时提示。 +- 符合条件的用户的默认思考强度升级为推荐级别。 +- 子 Agent 模型池(`[secondary_model]`)现已始终开启,实验开关与 `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` 退出选项已移除。 +- `Read` 新增可配置的字符上限,长行文件可续读,输出不再被反复截断。详见 [`read`](https://moonshotai.github.io/kimi-code/zh/configuration/config-files.html#read)。 +- minidb 会话索引读模型与全局搜索 worker 现已始终开启,实验开关由 `[database]` 配置段与 `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` / `KIMI_CODE_SEARCH_WORKER` 环境变量取代。详见 [`database`](https://moonshotai.github.io/kimi-code/zh/configuration/config-files.html#database)。 + +### 修复 + +- 修复了一些已知问题,并做了若干细节优化。更详细的变更记录见 [GitHub](https://github.com/MoonshotAI/kimi-code/blob/main/apps/kimi-code/CHANGELOG.md)。 + ## 0.41.0(2026-09-04) ### 新功能 diff --git a/gym/.gate b/gym/.gate new file mode 100644 index 00000000000..ce4a72b8594 --- /dev/null +++ b/gym/.gate @@ -0,0 +1 @@ +open \ No newline at end of file diff --git a/packages/acp-server/test/_helpers/scriptedProvider.ts b/packages/acp-server/test/_helpers/scriptedProvider.ts index f6c7cc03f7e..9a8df025d91 100644 --- a/packages/acp-server/test/_helpers/scriptedProvider.ts +++ b/packages/acp-server/test/_helpers/scriptedProvider.ts @@ -179,7 +179,7 @@ export function createScriptedProvider(): ScriptedProvider { }; // Identity/capability/model resolution delegates to the real registry (the // interface grew `resolveAdapterIdentity` / `resolveProviderBaseId` / - // `resolveCapability` / `explainCapability` / `resolve` — delegating keeps the + // `resolveCapability` / `resolve` — delegating keeps the // stub truthful and immune to further growth); only the requester is scripted. const real = new ProtocolAdapterRegistry(); const registry: IProtocolAdapterRegistryType = { @@ -188,7 +188,6 @@ export function createScriptedProvider(): ScriptedProvider { resolveAdapterIdentity: real.resolveAdapterIdentity.bind(real), resolveProviderBaseId: real.resolveProviderBaseId.bind(real), resolveCapability: real.resolveCapability.bind(real), - explainCapability: real.explainCapability.bind(real), resolve: (model: Model) => ({ ...real.resolve(model), requester }), // `createChatProvider` is called by `ModelImpl` (a package-internal method // not on the public interface); present at runtime, cast for the type gap. diff --git a/packages/agent-core-v2/docs/en/event-name.md b/packages/agent-core-v2/docs/en/event-name.md index 45fea24ed79..41610f4ae94 100644 --- a/packages/agent-core-v2/docs/en/event-name.md +++ b/packages/agent-core-v2/docs/en/event-name.md @@ -7,7 +7,7 @@ Naming conventions for states, events, actions, guards, and invoked actors in ag Classify an event by **what the receiver does with it**, not by whether it carries a payload. 1. **Command — imperative verb**. Asks the receiver to do something. Examples: `input.submit`, `input.steer`, `input.abort`, `input.remind`, `tool.abort`, `turn.abort`, `turn.drain`, `turn.notify`, `turn.spawn_tools`, `context.reset`. -2. **Fact — past participle**. Reports that something already happened; usually drives transitions or parent-level bookkeeping. Examples: `llm.sent`, `llm.done`, `llm.failed.syntax`, `llm.failed.remote`, `llm.retrying`, `llm.recovering`, `tool.done`, `tool.failed`, `tool.aborted`, `tool.detached`, `turn.reminders_consumed`, `todo.used`. Emitted events are facts by definition: `turn.started`, `turn.done`, `turn.failed`, `turn.aborted`, `turn.aborting`, `agent.created`, `agent.forked`, `agent.switched`, `agent.stopped`, `agent.failed`, `usage.updated`. +2. **Fact — past participle**. Reports that something already happened; usually drives transitions or parent-level bookkeeping. Examples: `llm.sent`, `llm.done`, `llm.failed.syntax`, `llm.failed.remote`, `llm.retrying`, `llm.recovering`, `tool.done`, `tool.failed`, `tool.aborted`, `tool.detached`, `turn.reminders_consumed`, `todo.used`. Emitted events are facts by definition: `turn.started`, `step.started`, `turn.done`, `turn.failed`, `turn.aborted`, `turn.aborting`, `agent.created`, `agent.forked`, `agent.switched`, `agent.stopped`, `agent.failed`, `usage.updated`. 3. **Data stream — noun (the data's own name)**. Delivers one piece of streaming data; the receiver accumulates or forwards it. Grouped under a `streaming` sub-namespace: `llm.streaming.part`, `llm.streaming.headers`, `llm.streaming.usage`, `llm.streaming.finish`, `llm.streaming.message_id`; also `tool.update`, `usage.record`. Boundary example: `llm.streaming.finish` carries completion metadata that feeds the accumulator (data stream, noun), while `llm.done` is the payload-free stream terminator that drives the transition (fact, past participle). diff --git a/packages/agent-core-v2/docs/en/llm.md b/packages/agent-core-v2/docs/en/llm.md index aef7c4d3730..7d339106c3b 100644 --- a/packages/agent-core-v2/docs/en/llm.md +++ b/packages/agent-core-v2/docs/en/llm.md @@ -5,10 +5,10 @@ llm is a standalone LLM request library inside the human layer (`src/human/llm/` ## Design Principles 1. **Minimal boundary: llm = "a single request"**. llm only handles request encoding/decoding and event emission. auth, usage accounting, HistoryMessage/meta, compaction, switch, the media file system, and Tool Message assembly are all out of scope — they either move up to the turn/agent layer or plug in as contribution points. -2. **Streaming-native; events are the contract**. The only outward surface is a single, purely serializable event stream (requester level: `llm.sent / streaming.headers / streaming.part / streaming.usage / streaming.finish / streaming.message_id / failed.syntax / failed.remote / done`; the machine level adds `llm.retrying / llm.recovering`, and `llm.sent` carries the most recent recovery record). Streaming and non-streaming are isomorphic (non-streaming also accumulates over the stream, just without deltas). Events are emitted as they arrive — no caching, no fallback. -3. **format masks inter-protocol differences; trait expresses provider dialects**. format lives at the protocol layer and handles encoding/decoding of requests, responses, errors, usage, and finish. trait is a bundle of hooks a provider attaches (endpoint, headers, convertMessage, buildParams, withThinking, etc.). Protocol differences must not leak into the machine or into requester decorators. +2. **Streaming-native; events are the contract**. The only outward surface is a single, purely serializable event stream (requester level: `llm.sent / streaming.headers / streaming.part / streaming.usage / streaming.finish / streaming.message_id / failed.syntax / failed.remote / done`; the turn level adds `llm.retrying / llm.recovering`, and `llm.sent` carries the most recent recovery record). Streaming and non-streaming are isomorphic (non-streaming also accumulates over the stream, just without deltas). Events are emitted as they arrive — no caching, no fallback. +3. **format masks inter-protocol differences; traits express provider customizations**. format lives at the protocol layer and handles encoding/decoding of requests, responses, errors, usage, and finish. Each protocol owns a typed trait interface (`OpenAITrait` / `OpenAIResponsesTrait` / `AnthropicTrait` / `GoogleGenAITrait`) exposing only the customization points that protocol actually consumes — a hook a protocol ignores is unrepresentable, never silently dead. format and trait never import each other: both speak only the neutral wire/chunk types in the protocol's `contract.ts`. The requester is the composition root — `generate` runs a fixed per-protocol pipeline (`planOpenAIRequest` and friends) that alternates pure format stages (lower → assemble → encode → stream parser) with trait hooks (cacheKey/thinking → convertMessage → mergeHistory → convertTool → buildParams → extractUsage), so customization is explicit data flow instead of a closure captured inside format. Endpoint/env resolution and default headers form the provider `connection`, error classification is a requester option, and model capability is a provider-variant field — none of them are format business. Each base's public seam is contract + trait + requester; format, lower, and patterns are internal to the requester pipeline — only bases code and tests may import them (lint-enforced). Protocol differences must not leak into the turn or into requester decorators. 4. **Two-layer error model**. Internally, code throws the SDK's native errors; local request validation throws the shared `SyntaxRequestFormatError` (`llm/syntax-errors.ts`), which the requester converts uniformly via `toLlmSyntaxErrorMessage`, with no intermediate layer. Externally there are only `llm.failed.syntax` (local message syntax errors, never retried) and `llm.failed.remote` (remote streaming errors, subdivided into connection / timeout / rate_limit / quota_exhausted / context_overflow / request_structure, etc.), converted by format at the boundary. -5. **Stateless core + state machine shell**. `generate(config, content, control)` is a stateless function; errors are delivered via onEvent, never thrown. The llm machine wraps a single request (messageResolvers, abort scope, event forwarding) and drives retry and recovery through the pure policy functions in retry.ts / recovery.ts: recovery re-sends with replacement messages produced by the pure `propose` function (attempt resets to 1), retry backs off in the `retrying` state (honoring Retry-After), and the machine emits `llm.recovering / llm.retrying` for each. Empty response is judged by `withEmptyResponseGuard` at the requester boundary and raised as `llm.failed.remote`, entering the same retry path. Abort is carried by an AbortController owned by the turn: the controller is passed into the machine and the request actor via `LlmInput.signal`, and the turn aborts it directly on `turn.abort`, with the request ending as `llm.failed.remote`; the request actor neither creates its own controller nor touches any signal on teardown, so a finished request can never abort a shared signal. The accumulator is held by the turn and fed by the event stream; on `llm.retrying / llm.recovering` the turn rolls it back and recreates it, so every attempt accumulates from zero while as much interrupted state as possible is preserved (the turn finishes the complete message out of the accumulator at `llm.done`). +5. **Stateless core + turn-driven orchestration**. `generate(config, content, control)` is a stateless function; errors are delivered via onEvent, never thrown. The turn machine invokes the request actor (`createRequestActor`) directly: the actor wraps a single request (messageResolvers, abort scope, event sendBack), and the turn drives retry and recovery through the pure policy functions in retry.ts / recovery.ts: recovery is a strategy chain (credential refresh via `credentialsRecovery`, then replacement-message strategies such as media degradation) whose pure `propose` yields a recovery action — the turn executes the action (invalidating credentials and/or swapping messages) and re-enters `thinking` with attempt reset to 1; retry backs off in the `retrying` state (honoring Retry-After), and the turn emits `llm.recovering / llm.retrying` for each. Empty response is judged by the turn at `llm.done` via the pure `emptyResponseError` and re-raised as `llm.failed.remote`, entering the same failure cascade. Abort is carried by an AbortController owned by the turn: the controller is passed into the request actor via `LlmInput.signal`, and the turn aborts it directly on `turn.abort`, with the request ending as `llm.failed.remote`; the request actor neither creates its own controller nor touches any signal on teardown, so a finished request can never abort a shared signal. The accumulator is held by the turn and fed by the event stream; on `llm.retrying / llm.recovering` the turn rolls it back and recreates it, so every attempt accumulates from zero while as much interrupted state as possible is preserved (the turn finishes the complete message out of the accumulator at `llm.done`). 6. **No silent fallback**. Configuration is taken exactly as given. For beta features, thinking, empty response, and similar scenarios, define explicit error conditions first, fail at request time, and guide the user to fix the configuration — never fall back silently. 7. **Every variable capability is a contribution point**. Providers, media upload/degradation, usage, traceId, and error recovery (compaction / media degradation) all plug in through extension points; the llm core contains none of these concepts. 8. **Data is data**. A model is pure, function-free data (endpoint url + model uniquely identifies a model), serializable and directly usable as generate input. The catalog is a derived `provider -> models` cache; the dependency direction only goes from models-dev into llm internals, never the reverse. @@ -25,23 +25,32 @@ llm/ ├── toolCallIdNormalizer.ts streamed tool call id dedup: repeated raw ids are remapped in order │ ├── protocol/ shared protocol layer (common across bases) -│ ├── base.ts ProtocolName / ProtocolBase -│ ├── format.ts ProtocolFormat: formatRequest + createStreamParser(sink callbacks) -│ ├── trait.ts ProtocolTrait: the full set of provider dialect hooks +│ ├── base.ts ProtocolName / ProtocolBase / ProtocolRequesterOptions / TraitContext +│ ├── format.ts ProtocolFormat: createStreamParser(sink callbacks + resolveUsage option) +│ ├── connection.ts ProviderConnection: endpoint env declaration + default headers +│ ├── thinking.ts ThinkingApplication / ThinkingStrategy / applyThinking │ └── patterns.ts / rewrite.ts MLIR-style Pattern Rewriter (Message N:M conversion) │ ├── requester/ │ ├── requester.ts LlmRequester.generate(config, content, control); -│ │ ExtraParams typed per protocol {openai?, responses?, anthropic?, googleGenai?} -│ ├── machine.ts llm state machine (single request + retry/recovery + empty response -│ │ judgment; emits llm.retrying / llm.recovering) -│ ├── retry.ts / recovery.ts pure retry/recovery policy functions (driven by the llm machine; propose is pure) -│ ├── empty-response.ts withEmptyResponseGuard: judges empty responses at finish and raises llm.failed.remote +│ │ ExtraParams typed per protocol {openai?, responses?, anthropic?, googleGenai?}; +│ │ LlmRequestConfig.credentials: credential contribution point +│ │ (resolve/canRecover/invalidate), resolved per attempt by the caller; +│ │ factories and the credentialsRecovery strategy live in human/credentials +│ │ (staticCredentials / oauthCredentials; kimiOAuthCredentialProvider adapts +│ │ Kimi OAuth tokens); the runWithCredentialRecovery / +│ │ streamWithCredentialRecovery executors for direct callers live in +│ │ llm-adapter/model/credential-recovery +│ ├── actor.ts request actor: a fromCallback wrapping a single request +│ │ (messageResolvers, abort scope, event sendBack); invoked by the turn +│ ├── retry.ts / recovery.ts pure retry/recovery policy functions (driven by the turn machine; propose is pure) +│ ├── empty-response.ts emptyResponseError: pure empty-response judgment; the turn raises it as llm.failed.remote at llm.done │ └── bases/ four protocol bases: openai / openai-responses / anthropic / google-genai -│ each with format / lower / patterns / capability / extra-params / requester +│ each with contract / format / lower / patterns / capability / extra-params / trait / requester +│ (public seam: contract / trait / requester; format / lower / patterns stay internal) │ ├── provider/ -│ ├── definition.ts ProviderDefinition{id, protocols{base+trait}, media, models} +│ ├── definition.ts ProviderDefinition{id, protocols{base+trait+connection+convertError+capability}, media, models} │ │ createProvider() (no registry) → Provider{listModels, resolveModel, createRequester} │ └── providers/ built-in providers such as standard (registered via contribution points) │ @@ -51,12 +60,15 @@ llm/ └── media/ media contribution points: cache / degrade / ref / resolver / store / upload ``` -Request lifecycle: `generate` receives (config, content, control) → format lowers the generic Message[] through the Pattern Rewriter into protocol requestParams → internalGenerate calls the official SDK → streaming chunks are converted by the stateless parser callbacks into `llm.streaming.part / streaming.usage / streaming.finish / streaming.message_id` events → errors are converted by format into `llm.failed.*`; on success the requester emits `llm.done`, on failure it ends with `llm.failed.syntax / llm.failed.remote` and never emits `llm.done`. `withEmptyResponseGuard` judges empty responses at finish and raises `llm.failed.remote`; the llm machine first tries recovery on `llm.failed.remote` (replacement messages from the pure `propose` function, emitting `llm.recovering`), then retries with backoff (honoring Retry-After, emitting `llm.retrying`), and only lands in the failed final state once attempts are exhausted. The upper-layer turn holds the HistoryAccumulator, fed by the event stream, rolls it back and recreates it on `llm.retrying / llm.recovering`, and finishes the complete message at `llm.done`; usage accounting, tracing, compaction, and media degradation all attach to the event stream as plugins/contribution points. +Request lifecycle: `generate` receives (config, content, control) → the caller resolves `config.credentials` into a fully-credentialed model before each attempt (the request actor on the machine path), so requests always carry fresh credentials and a credential-refresh recovery (recoverable 401 → `credentials.invalidate()`, emitted as `llm.recovering` with strategy `credentials`) naturally re-resolves on the re-send (direct callers outside the state machines — ping, generate, full compaction, media upload — share the same single-retry recovery through `runWithCredentialRecovery` / `streamWithCredentialRecovery`) → the requester's `plan*` function composes pure format stages with trait hooks into protocol requestParams (format lowers the generic Message[] through the Pattern Rewriter; trait adjusts kwargs, converted messages, history, tools, and final params in between) → internalGenerate calls the official SDK → streaming chunks are converted by the stateless parser callbacks into `llm.streaming.part / streaming.usage / streaming.finish / streaming.message_id` events → errors are converted by format into `llm.failed.*`; on success the requester emits `llm.done`, on failure it ends with `llm.failed.syntax / llm.failed.remote` and never emits `llm.done`. At `llm.done` the turn judges empty responses via `emptyResponseError` and re-raises them as `llm.failed.remote`; the turn machine first tries recovery on `llm.failed.remote` (the strategy chain — credential refresh on a recoverable 401, then replacement-message strategies — via the pure `propose`, emitting `llm.recovering`), then retries with backoff (honoring Retry-After, emitting `llm.retrying`), and only fails the turn once attempts are exhausted. The turn holds the HistoryAccumulator, fed by the event stream, rolls it back and recreates it on `llm.retrying / llm.recovering`, and finishes the complete message at `llm.done`; usage accounting, tracing, compaction, and media degradation all attach to the event stream as plugins/contribution points. ## Rejected Schemes (do not reintroduce) -- Splitting llmActor / llmStreamActor into two actors — a single machine; non-streaming also accumulates over the stream. +- Splitting the request actor into llmActor / llmStreamActor — one actor per request; non-streaming also accumulates over the stream. +- A dedicated llm state machine wrapping the request actor — the turn machine invokes the actor directly and owns retry/recovery; the extra machine layer carried no state anyone consumed. - DDD domain-method wrapping (Generation Domain, etc.) — use the format/trait/provider layering instead. +- A single cross-protocol trait bag holding every vendor hook (the old ProtocolTrait) — per-protocol typed traits, composed by the requester's request pipeline. +- Binding the trait into the format (a `createOpenAIFormat(trait)` closure, or trait hooks passed as formatRequest options) — the requester pipeline alternates format stages and trait hooks explicitly; the two sides only share the neutral `contract.ts` types. - Functional `toWireMessage` / `WireAdapter` naming — use an adapter interface; no "Wire" in names. - Provider registry / `defineProvider` — `createProvider` exporting a const. - Hoisting system messages out of their position on egress — system messages stay in place in history and are converted in place. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index ab260383410..153fbcfaa81 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 82 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 76 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -47,11 +47,6 @@ // workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts // workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts // Agent -// activityView.background src/agent/activityView/activityViewService.ts -// activityView.current src/agent/activityView/activityViewService.ts -// activityView.lastTurn src/agent/activityView/activityViewService.ts -// activityView.lifecycle src/agent/activityView/activityViewService.ts -// activityView.turn src/agent/activityView/activityViewService.ts // agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -76,7 +71,6 @@ // llmRequester.turnConfigs src/agent/llmRequester/llmRequesterService.ts // loop.disposing src/agent/loop/loopService.ts // loop.lastRequestTraceId src/agent/loop/loopService.ts -// loop.nextReservedTurnId src/agent/loop/loopService.ts // mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts // mcp.discoveryWritesReady src/agent/mcp/mcpService.ts // mcp.mcpToolsByServer src/agent/mcp/mcpService.ts @@ -660,7 +654,8 @@ export interface SessionStateSnapshot { }; 'sessionActivity.folds': Map; + compacting: boolean; lastTurnReason?: 'completed' | 'cancelled' | 'failed'; }>; // src/session/sessionLog/sessionLogService.ts @@ -704,339 +699,6 @@ export type SessionStateKey = keyof SessionStateSnapshot; /** Agent-scope keys registered into IAgentStateService. */ export interface AgentStateSnapshot { - // src/agent/activityView/activityViewService.ts - 'activityView.background': Map; - 'activityView.current': /* AgentActivityState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly lifecycle: /* ActivityViewLifecycle — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'ready' | 'disposed'; - readonly turn?: /* ActivityTurnState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly turnId: number; - readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'user'; - readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - }[]; - readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly name: string; - readonly mediaType: string; - readonly size: number; - readonly path: string; - }[]; - } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'skill_activation'; - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly name: string; - readonly mediaType: string; - readonly size: number; - readonly path: string; - }[]; - } | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'plugin_command'; - readonly activationId: string; - readonly pluginId: string; - readonly commandName: string; - readonly commandArgs?: string; - readonly trigger: 'user-slash'; - } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'injection'; - readonly variant: string; - readonly ownerPromptId?: string; - readonly disclosure?: unknown; - } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'shell_command'; - readonly phase: 'input' | 'output'; - readonly isError?: boolean; - } | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'compaction_summary'; - } | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'system_trigger'; - readonly name: string; - } | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'task'; - readonly taskId: string; - readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; - readonly notificationId: string; - } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_job'; - readonly jobId: string; - readonly cron: string; - readonly recurring: boolean; - readonly coalescedCount: number; - readonly stale: boolean; - } | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_missed'; - readonly count: number; - } | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'hook_result'; - readonly event: string; - readonly blocked?: boolean; - } | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'retry'; - readonly trigger?: string; - }; - readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - readonly stream?: 'tool_call' | 'assistant' | 'thinking'; - readonly step: number; - readonly ending: boolean; - readonly endingReason?: 'error' | 'aborted' | 'max_steps'; - readonly retry?: /* ActivityRetryState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName?: string; - readonly statusCode?: number; - }; - readonly pendingApprovals: readonly /* ApprovalRef — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly approvalId: string; - readonly toolCallId?: string; - readonly since: number; - }[]; - readonly activeToolCalls: readonly /* ToolCallRef — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly toolCallId: string; - readonly name: string; - readonly since: number; - }[]; - readonly since: number; - }; - readonly lastTurn?: /* ActivityLastTurnState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly turnId: number; - readonly reason: /* TurnEndReason — packages/agent-core-v2/src/agent/loop/turnEvents.ts */ 'completed' | 'cancelled' | 'failed' | 'blocked'; - readonly durationMs?: number; - readonly at: number; - }; - readonly background: readonly /* BackgroundRef — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly kind: string; - readonly id: string; - readonly since: number; - }[]; - }; - 'activityView.lastTurn': /* ActivityLastTurnState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly turnId: number; - readonly reason: /* TurnEndReason — packages/agent-core-v2/src/agent/loop/turnEvents.ts */ 'completed' | 'cancelled' | 'failed' | 'blocked'; - readonly durationMs?: number; - readonly at: number; - } | undefined; - 'activityView.lifecycle': /* ActivityViewLifecycle — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'ready' | 'disposed'; - 'activityView.turn': /* MutableTurn — packages/agent-core-v2/src/agent/activityView/activityViewService.ts */ { - phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - stream: 'tool_call' | 'assistant' | 'thinking' | undefined; - step: number; - ending: boolean; - endingReason: 'error' | 'aborted' | 'max_steps' | undefined; - retry: /* ActivityRetryState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName?: string; - readonly statusCode?: number; - } | undefined; - pendingApprovals: Map; - activeToolCalls: Map; - since: number; - turnId: number; - origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'user'; - readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - }[]; - readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly name: string; - readonly mediaType: string; - readonly size: number; - readonly path: string; - }[]; - } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'skill_activation'; - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly name: string; - readonly mediaType: string; - readonly size: number; - readonly path: string; - }[]; - } | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'plugin_command'; - readonly activationId: string; - readonly pluginId: string; - readonly commandName: string; - readonly commandArgs?: string; - readonly trigger: 'user-slash'; - } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'injection'; - readonly variant: string; - readonly ownerPromptId?: string; - readonly disclosure?: unknown; - } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'shell_command'; - readonly phase: 'input' | 'output'; - readonly isError?: boolean; - } | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'compaction_summary'; - } | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'system_trigger'; - readonly name: string; - } | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'task'; - readonly taskId: string; - readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; - readonly notificationId: string; - } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_job'; - readonly jobId: string; - readonly cron: string; - readonly recurring: boolean; - readonly coalescedCount: number; - readonly stale: boolean; - } | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_missed'; - readonly count: number; - } | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'hook_result'; - readonly event: string; - readonly blocked?: boolean; - } | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'retry'; - readonly trigger?: string; - }; - snapshot: () => /* ActivityTurnState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly turnId: number; - readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'user'; - readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - }[]; - readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly name: string; - readonly mediaType: string; - readonly size: number; - readonly path: string; - }[]; - } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'skill_activation'; - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; - readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill'; - readonly skillType?: string; - readonly skillPath?: string; - readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; - readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly name: string; - readonly mediaType: string; - readonly size: number; - readonly path: string; - }[]; - } | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'plugin_command'; - readonly activationId: string; - readonly pluginId: string; - readonly commandName: string; - readonly commandArgs?: string; - readonly trigger: 'user-slash'; - } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'injection'; - readonly variant: string; - readonly ownerPromptId?: string; - readonly disclosure?: unknown; - } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'shell_command'; - readonly phase: 'input' | 'output'; - readonly isError?: boolean; - } | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'compaction_summary'; - } | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'system_trigger'; - readonly name: string; - } | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'task'; - readonly taskId: string; - readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; - readonly notificationId: string; - } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_job'; - readonly jobId: string; - readonly cron: string; - readonly recurring: boolean; - readonly coalescedCount: number; - readonly stale: boolean; - } | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'cron_missed'; - readonly count: number; - } | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'hook_result'; - readonly event: string; - readonly blocked?: boolean; - } | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'retry'; - readonly trigger?: string; - }; - readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying'; - readonly stream?: 'tool_call' | 'assistant' | 'thinking'; - readonly step: number; - readonly ending: boolean; - readonly endingReason?: 'error' | 'aborted' | 'max_steps'; - readonly retry?: /* ActivityRetryState — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName?: string; - readonly statusCode?: number; - }; - readonly pendingApprovals: readonly /* ApprovalRef — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly approvalId: string; - readonly toolCallId?: string; - readonly since: number; - }[]; - readonly activeToolCalls: readonly /* ToolCallRef — packages/agent-core-v2/src/agent/activityView/activityView.ts */ { - readonly toolCallId: string; - readonly name: string; - readonly since: number; - }[]; - readonly since: number; - }; - } | undefined; // src/agent/agentsMdReminder/agentsMdReminderService.ts 'agentsMdReminder.cwd': string | undefined; 'agentsMdReminder.known': Set; @@ -1044,7 +706,7 @@ export interface AgentStateSnapshot { // src/agent/contextMemory/contextOps.ts // replayable · durable · undoable — folds: ContextAppendMessage, ContextAppendLoopEvent, ContextClear, ContextApplyCompaction 'contextMemory': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/llm-adapter/contract/message.ts */ { - readonly role: /* Role — packages/agent-core-v2/src/human/llm/message.ts */ 'user' | 'assistant' | 'system' | 'tool'; + readonly role: /* Role — packages/agent-core-v2/src/human/llm/message.ts */ 'user' | 'system' | 'assistant' | 'tool'; readonly name?: string; readonly content: (/* ContentPart — packages/agent-core-v2/src/human/llm/message.ts */ /* TextPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'text'; @@ -1321,7 +983,6 @@ export interface AgentStateSnapshot { // src/agent/loop/loopService.ts 'loop.disposing': boolean; 'loop.lastRequestTraceId': string | undefined; - 'loop.nextReservedTurnId': number | undefined; // src/agent/loop/turnOps.ts // replayable · durable — folds: ContextAppendLoopEvent, TurnPrompt, TurnSteer, ContextUndo, ContextApplyCompaction, ContextClear, TurnCancel, TurnEnded 'turn': /* TurnModelState — packages/agent-core-v2/src/agent/loop/turnOps.ts */ { @@ -1607,7 +1268,7 @@ export interface AgentStateSnapshot { }; // src/features/swarm/swarmOps.ts // replayable · durable — folds: SwarmModeEnter, SwarmModeExit - 'swarm': 'task' | 'tool' | 'manual' | null; + 'swarm': 'tool' | 'task' | 'manual' | null; // src/features/tower/towerOps.ts // replayable · durable — folds: TowerModeEnter, TowerModeExit 'tower': boolean; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 662429714c9..145802f8805 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -847,6 +847,7 @@ interface TurnPromptPayload { /** PromptOrigin */ origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; promptId?: string; + turnId?: number; } /** diff --git a/packages/agent-core-v2/docs/zh/event-name.md b/packages/agent-core-v2/docs/zh/event-name.md index 0e7b14925ef..4033f1de631 100644 --- a/packages/agent-core-v2/docs/zh/event-name.md +++ b/packages/agent-core-v2/docs/zh/event-name.md @@ -7,7 +7,7 @@ agent-core-v2 各 XState 状态机中状态、事件、action、guard、被 invo 按**接收方拿事件做什么**分类,而不是按是否携带 payload。 1. **命令 —— 动词原形**。要求接收方做事。例:`input.submit`、`input.steer`、`input.abort`、`input.remind`、`tool.abort`、`turn.abort`、`turn.drain`、`turn.notify`、`turn.spawn_tools`、`context.reset`。 -2. **事实 —— 过去分词**。报告某事已发生,通常驱动转移或父级记账。例:`llm.sent`、`llm.done`、`llm.failed.syntax`、`llm.failed.remote`、`llm.retrying`、`llm.recovering`、`tool.done`、`tool.failed`、`tool.aborted`、`tool.detached`、`turn.reminders_consumed`、`todo.used`。emitted 事件天然是事实:`turn.started`、`turn.done`、`turn.failed`、`turn.aborted`、`turn.aborting`、`agent.created`、`agent.forked`、`agent.switched`、`agent.stopped`、`agent.failed`、`usage.updated`。 +2. **事实 —— 过去分词**。报告某事已发生,通常驱动转移或父级记账。例:`llm.sent`、`llm.done`、`llm.failed.syntax`、`llm.failed.remote`、`llm.retrying`、`llm.recovering`、`tool.done`、`tool.failed`、`tool.aborted`、`tool.detached`、`turn.reminders_consumed`、`todo.used`。emitted 事件天然是事实:`turn.started`、`step.started`、`turn.done`、`turn.failed`、`turn.aborted`、`turn.aborting`、`agent.created`、`agent.forked`、`agent.switched`、`agent.stopped`、`agent.failed`、`usage.updated`。 3. **数据流 —— 名词(即数据名)**。把一份流式数据送达,接收方累积或转发。归入 `streaming` 子命名空间:`llm.streaming.part`、`llm.streaming.headers`、`llm.streaming.usage`、`llm.streaming.finish`、`llm.streaming.message_id`;另有 `tool.update`、`usage.record`。 判别示例:`llm.streaming.finish` 携带完成元数据喂给累加器(数据流,名词),而 `llm.done` 是无 payload 的流终止哨兵、驱动转移(事实,过去分词)。 diff --git a/packages/agent-core-v2/docs/zh/llm.md b/packages/agent-core-v2/docs/zh/llm.md index 8d38e7ee7d8..0baf49e7aab 100644 --- a/packages/agent-core-v2/docs/zh/llm.md +++ b/packages/agent-core-v2/docs/zh/llm.md @@ -5,10 +5,10 @@ llm 是 human 层内一个独立的 LLM 请求库(`src/human/llm/`),提供 ## 设计原则 1. **边界极简:llm = 「一次请求」**。llm 只负责请求编解码与事件回传。auth、usage 统计、HistoryMessage/meta、compaction、switch、媒体文件系统、Tool Message 拼装全部不属于 llm——要么上移到 turn/agent 层,要么以贡献点接入。 -2. **流式原生、事件即契约**。对外只暴露一条纯可序列化的事件流(requester 层:`llm.sent / streaming.headers / streaming.part / streaming.usage / streaming.finish / streaming.message_id / failed.syntax / failed.remote / done`;machine 层补充 `llm.retrying / llm.recovering`,`llm.sent` 携带最近一次 recovery 记录),流式与非流式同构(非流式也走流式累积,只是不发 delta);事件收到即发,不缓存、不兜底。 -3. **format 屏蔽协议间差异,trait 表达 provider 方言**。format 位于 protocol 层,负责请求、响应、错误、usage 和 finish 的编解码;trait 是 provider 附加的一包 hooks(endpoint、headers、convertMessage、buildParams、withThinking 等)。协议差异不允许泄漏到 machine 或 requester 的装饰层。 +2. **流式原生、事件即契约**。对外只暴露一条纯可序列化的事件流(requester 层:`llm.sent / streaming.headers / streaming.part / streaming.usage / streaming.finish / streaming.message_id / failed.syntax / failed.remote / done`;turn 层补充 `llm.retrying / llm.recovering`,`llm.sent` 携带最近一次 recovery 记录),流式与非流式同构(非流式也走流式累积,只是不发 delta);事件收到即发,不缓存、不兜底。 +3. **format 屏蔽协议间差异,trait 表达 provider 定制**。format 位于 protocol 层,负责请求、响应、错误、usage 和 finish 的编解码。每种协议拥有自己的类型化 trait 接口(`OpenAITrait` / `OpenAIResponsesTrait` / `AnthropicTrait` / `GoogleGenAITrait`),只暴露该协议实际消费的定制点——协议不支持的 hook 在类型上无法表达,而不是配了却静默无效。format 与 trait 互不 import:双方只共享协议 `contract.ts` 里的中立 wire/chunk 类型。requester 是组合根——`generate` 执行每个协议固定的流水线(`planOpenAIRequest` 等),交替调用纯 format 阶段(lower → assemble → encode → stream parser)与 trait hooks(cacheKey/thinking → convertMessage → mergeHistory → convertTool → buildParams → extractUsage),定制逻辑是显式的数据流,而不是捕获在 format 闭包里。endpoint/环境变量解析与默认 headers 属于 provider `connection`,错误归类是 requester 选项,模型能力是 provider variant 字段——都不是 format 的职责。每个 base 的公开接缝是 contract + trait + requester;format、lower、patterns 是 requester 流水线的内部模块——只有 bases 内代码和测试可以 import(lint 强制)。协议差异不允许泄漏到 turn 或 requester 的装饰层。 4. **错误两层模型**。内部 throw SDK 原生错误;本地请求校验抛共享的 `SyntaxRequestFormatError`(`llm/syntax-errors.ts`),由 requester 经 `toLlmSyntaxErrorMessage` 统一转换,不加中间层。对外只有 `llm.failed.syntax`(本地消息语法错误,不重试)与 `llm.failed.remote`(远程流式错误,细分为 connection/timeout/rate_limit/quota_exhausted/context_overflow/request_structure 等),由 format 在边界完成转换。 -5. **无状态内核 + 状态机外壳**。`generate(config, content, control)` 是无状态函数,错误走 onEvent 不 throw;llm machine 包装单次请求(messageResolvers、abort 作用域、事件转发),并借助 retry.ts / recovery.ts 的纯策略函数驱动重试与 recovery:recovery 由纯函数 `propose` 产出替换消息直接重发(attempt 重置为 1),重试走 `retrying` 状态的 backoff(尊重 Retry-After),两者分别对外补发 `llm.recovering / llm.retrying` 事件;empty response 由 `withEmptyResponseGuard` 在 requester 边界判定并转为 `llm.failed.remote`,进入同一重试路径;abort 由 turn 持有的 AbortController 承载:controller 经 `LlmInput.signal` 传入 machine 与 request actor,turn 在 `turn.abort` 时直接 abort 它,请求随即以 `llm.failed.remote` 收尾;request actor 不自建 controller、回收时不触碰任何 signal,正常完成的请求绝不可能误 abort 共享 signal。累积器由 turn 持有并随事件流喂入,在 `llm.retrying / llm.recovering` 时 rollback 并重建,每次 attempt 从零累积,从而尽可能保留中断现场(turn 在 `llm.done` 时从累加器 finish 出完整消息)。 +5. **无状态内核 + turn 驱动的编排**。`generate(config, content, control)` 是无状态函数,错误走 onEvent 不 throw;turn machine 直接 invoke 请求 actor(`createRequestActor`):actor 包装单次请求(messageResolvers、abort 作用域、事件 sendBack),turn 借助 retry.ts / recovery.ts 的纯策略函数驱动重试与 recovery:recovery 是一条策略链(`credentialsRecovery` 的凭证刷新在前,媒体降级等替换消息策略在后),由纯函数 `propose` 产出恢复动作,turn 负责解释执行(invalidate 凭证和/或替换消息)并重进 `thinking`(attempt 重置为 1),重试走 `retrying` 状态的 backoff(尊重 Retry-After),两者分别由 turn 对外补发 `llm.recovering / llm.retrying` 事件;empty response 由 turn 在 `llm.done` 时经纯函数 `emptyResponseError` 判定并重新转为 `llm.failed.remote`,进入同一失败级联;abort 由 turn 持有的 AbortController 承载:controller 经 `LlmInput.signal` 传入 request actor,turn 在 `turn.abort` 时直接 abort 它,请求随即以 `llm.failed.remote` 收尾;request actor 不自建 controller、回收时不触碰任何 signal,正常完成的请求绝不可能误 abort 共享 signal。累积器由 turn 持有并随事件流喂入,在 `llm.retrying / llm.recovering` 时 rollback 并重建,每次 attempt 从零累积,从而尽可能保留中断现场(turn 在 `llm.done` 时从累加器 finish 出完整消息)。 6. **不兜底**。配置是什么就是什么;beta 特性、thinking、empty response 等场景先定义明确报错条件,在请求阶段报错并引导用户修正,而不是静默兜底。 7. **一切可变能力都是贡献点**。provider、媒体上传/降级、usage、traceId、错误恢复(compaction/媒体降级)都通过扩展点接入,llm 内核不含这些概念。 8. **数据即数据**。model 是无函数的纯数据(endpoint url + model 唯一标识一个模型),可序列化、可直接作为 generate 输入;catalog 是 `provider -> models` 的派生缓存,依赖方向只能从 models-dev 指向 llm 内部,不能反向依赖。 @@ -25,23 +25,32 @@ llm/ ├── toolCallIdNormalizer.ts 流式 tool call id 去重:重复的 raw id 按序重映射为新 id │ ├── protocol/ 协议通用层(跨基座共享) -│ ├── base.ts ProtocolName / ProtocolBase -│ ├── format.ts ProtocolFormat:formatRequest + createStreamParser(sink 回调) -│ ├── trait.ts ProtocolTrait:provider 方言 hooks 全集 +│ ├── base.ts ProtocolName / ProtocolBase / ProtocolRequesterOptions / TraitContext +│ ├── format.ts ProtocolFormat:createStreamParser(sink 回调 + resolveUsage 选项) +│ ├── connection.ts ProviderConnection:endpoint 环境变量声明 + 默认 headers +│ ├── thinking.ts ThinkingApplication / ThinkingStrategy / applyThinking │ └── patterns.ts / rewrite.ts MLIR 式 Pattern Rewriter(Message N:M 转换) │ ├── requester/ │ ├── requester.ts LlmRequester.generate(config, content, control); -│ │ ExtraParams 按协议带类型 {openai?, responses?, anthropic?, googleGenai?} -│ ├── machine.ts llm 状态机(单次请求 + 重试/恢复 + empty response 判定; -│ │ 对外补发 llm.retrying / llm.recovering) -│ ├── retry.ts / recovery.ts 重试/恢复策略纯函数(由 llm machine 驱动;propose 为纯函数) -│ ├── empty-response.ts withEmptyResponseGuard:finish 时判定空响应并转为 llm.failed.remote +│ │ ExtraParams 按协议带类型 {openai?, responses?, anthropic?, googleGenai?}; +│ │ LlmRequestConfig.credentials:凭证贡献点 +│ │ (resolve/canRecover/invalidate),由调用方在每次 attempt 前解析; +│ │ 工厂与 credentialsRecovery 策略位于 human/credentials +│ │ (staticCredentials / oauthCredentials;kimiOAuthCredentialProvider +│ │ 适配 Kimi OAuth token);供 direct 调用方使用的 +│ │ runWithCredentialRecovery / streamWithCredentialRecovery 执行器 +│ │ 位于 llm-adapter/model/credential-recovery +│ ├── actor.ts 请求 actor:包装单次请求的 fromCallback +│ │ (messageResolvers、abort 作用域、事件 sendBack),由 turn invoke +│ ├── retry.ts / recovery.ts 重试/恢复策略纯函数(由 turn machine 驱动;propose 为纯函数) +│ ├── empty-response.ts emptyResponseError:空响应判定纯函数,由 turn 在 llm.done 时转为 llm.failed.remote │ └── bases/ 四个协议基座:openai / openai-responses / anthropic / google-genai -│ 各自含 format / lower / patterns / capability / extra-params / requester +│ 各自含 contract / format / lower / patterns / capability / extra-params / trait / requester +│ (公开接缝:contract / trait / requester;format / lower / patterns 保持内部) │ ├── provider/ -│ ├── definition.ts ProviderDefinition{id, protocols{base+trait}, media, models} +│ ├── definition.ts ProviderDefinition{id, protocols{base+trait+connection+convertError+capability}, media, models} │ │ createProvider()(无 registry)→ Provider{listModels, resolveModel, createRequester} │ └── providers/ standard 等内建 provider(经贡献点注册) │ @@ -51,12 +60,15 @@ llm/ └── media/ 媒体贡献点:cache / degrade / ref / resolver / store / upload ``` -请求生命周期:`generate` 收到 (config, content, control) → format 将通用 Message[] 经 Pattern Rewriter 降低为协议 requestParam → internalGenerate 调用官方 SDK → 流式 chunk 经无状态 parser 回调转换为 `llm.streaming.part / streaming.usage / streaming.finish / streaming.message_id` 事件 → 错误由 format 转换为 `llm.failed.*`;成功时 requester 发出 `llm.done`,失败时以 `llm.failed.syntax / llm.failed.remote` 收尾、不再发 `llm.done`。`withEmptyResponseGuard` 在 finish 时判定空响应并转为 `llm.failed.remote`;llm machine 对 `llm.failed.remote` 先尝试 recovery(纯函数 `propose` 产出替换消息,发 `llm.recovering`),再按策略 backoff 重试(尊重 Retry-After,发 `llm.retrying`),耗尽后才以 failed 终态收尾。上层的 turn 持有 HistoryAccumulator 随事件流累积,在 `llm.retrying / llm.recovering` 时 rollback 并重建累加器,`llm.done` 时 finish 出完整消息;usage 统计、trace、compaction、媒体降级均以插件/贡献点身份挂接在事件流上。 +请求生命周期:`generate` 收到 (config, content, control) → 调用方在每次 attempt 前把 `config.credentials` 解析成带完整凭证的 model(machine 路径由 request actor 完成),请求因此始终携带新鲜凭证,而凭证刷新恢复(可恢复的 401 → `credentials.invalidate()`,以 `llm.recovering`(strategy 为 `credentials`)发出)在重发时自然重新解析(不经状态机的 direct 调用方——ping、generate、full compaction、媒体上传——通过 `runWithCredentialRecovery` / `streamWithCredentialRecovery` 共享同一套单次重试恢复) → requester 的 `plan*` 函数将纯 format 阶段与 trait hooks 组合为协议 requestParams(format 将通用 Message[] 经 Pattern Rewriter 降低,trait 在其间调整 kwargs、转换消息、合并历史、转换 tools 并收尾 params) → internalGenerate 调用官方 SDK → 流式 chunk 经无状态 parser 回调转换为 `llm.streaming.part / streaming.usage / streaming.finish / streaming.message_id` 事件 → 错误由 format 转换为 `llm.failed.*`;成功时 requester 发出 `llm.done`,失败时以 `llm.failed.syntax / llm.failed.remote` 收尾、不再发 `llm.done`。turn 在 `llm.done` 时经 `emptyResponseError` 判定空响应并重新转为 `llm.failed.remote`;turn machine 对 `llm.failed.remote` 先尝试恢复(策略链——可恢复 401 的凭证刷新在前、替换消息策略在后——经纯函数 `propose` 产出,发 `llm.recovering`),再按策略 backoff 重试(尊重 Retry-After,发 `llm.retrying`),耗尽后才将 turn 置为失败。turn 持有 HistoryAccumulator 随事件流累积,在 `llm.retrying / llm.recovering` 时 rollback 并重建累加器,`llm.done` 时 finish 出完整消息;usage 统计、trace、compaction、媒体降级均以插件/贡献点身份挂接在事件流上。 ## 已被否决的方案(不要再引入) -- 拆分 llmActor / llmStreamActor 两个 actor —— 单一 machine,非流式也走流式累积。 +- 拆分 llmActor / llmStreamActor 两个 actor —— 每次请求一个 actor,非流式也走流式累积。 +- 给请求 actor 再包一层专用 llm 状态机 —— turn machine 直接 invoke actor 并持有重试/recovery,额外的 machine 层没有任何被消费的状态。 - DDD 领域方法包装(Generation Domain 等)—— 用 format/trait/provider 分层。 +- 用一个跨协议 trait 大包承载所有厂商 hooks(旧的 ProtocolTrait)—— 按协议拆分的类型化 trait,由 requester 的请求流水线组合。 +- 把 trait 绑定进 format(`createOpenAIFormat(trait)` 闭包,或把 trait hooks 作为 formatRequest 选项传入)—— requester 流水线显式交替调用 format 阶段与 trait hooks,双方只共享中立的 `contract.ts` 类型。 - 函数式 `toWireMessage` / `WireAdapter` 命名 —— adapter interface,命名中不出现 Wire。 - Provider registry / `defineProvider` —— `createProvider` 导出 const。 - 出站时把 system 消息 hoisting 出原位 —— system 消息留在历史原位转换。 diff --git a/packages/agent-core-v2/scripts/check-import-boundaries.mjs b/packages/agent-core-v2/scripts/check-import-boundaries.mjs index 4ca27af4ce2..b8c495730c6 100644 --- a/packages/agent-core-v2/scripts/check-import-boundaries.mjs +++ b/packages/agent-core-v2/scripts/check-import-boundaries.mjs @@ -3,8 +3,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; -const __dirname = import.meta.dirname; -const PKG_ROOT = resolve(__dirname, '..'); +const PKG_ROOT = resolve(import.meta.dirname, '..'); export const SRC_ROOT = join(PKG_ROOT, 'src'); const TEST_ROOT = join(PKG_ROOT, 'test'); const HUMAN_ROOT = join(SRC_ROOT, 'human'); @@ -13,6 +12,32 @@ const LOOP_MACHINE_ADAPTER_ROOT = join(SRC_ROOT, 'agent/loop/machine'); const SELF_PACKAGE_PREFIX = '@moonshot-ai/agent-core-v2/'; const KOSONG_PATH_RE = /(?:^|\/)kosong(?:\/|$)/; +const TRAIT_FILE_RE = /\/trait\.ts$/; +const FORMAT_LOWER_FILE_RE = /\/bases\/[^/]+\/(?:format|lower)\.ts$/; +const FORMAT_LOWER_MODULE_RE = /\/bases\/[^/]+\/(?:format|lower)$/; +const TRAIT_MODULE_RE = /\/trait$/; +const BASES_DIR_RE = /\/llm\/requester\/bases(?:\/|$)/; +const BASES_INTERNAL_MODULE_RE = + /\/llm\/requester\/bases\/[^/]+\/(?:format|lower|patterns|reasoning-key)$/; +const TEST_DIR_RE = /\/test(?:\/|$)/; + +function traitBoundaryViolation(absFile, targetAbs, specifier) { + const message = `format and trait never import each other ('${specifier}') — both sides speak only the neutral wire/chunk types in the protocol's contract.ts`; + if (TRAIT_FILE_RE.test(absFile) && FORMAT_LOWER_MODULE_RE.test(targetAbs)) { + return message; + } + if (FORMAT_LOWER_FILE_RE.test(absFile) && TRAIT_MODULE_RE.test(targetAbs)) { + return message; + } + return undefined; +} + +function basesInternalViolation(absFile, targetAbs, specifier) { + if (!BASES_INTERNAL_MODULE_RE.test(targetAbs)) return undefined; + if (TRAIT_FILE_RE.test(absFile) && FORMAT_LOWER_MODULE_RE.test(targetAbs)) return undefined; + if (BASES_DIR_RE.test(absFile) || TEST_DIR_RE.test(absFile)) return undefined; + return `protocol format modules are internal to the requester pipeline ('${specifier}') — only llm/requester/bases code and tests may import format/lower/patterns; everyone else speaks contract/trait/requester`; +} const HUMAN_VOCABULARY = new Set([ 'llm/message', @@ -30,6 +55,7 @@ const HUMAN_VOCABULARY = new Set([ 'interaction/machine', 'interaction/facade', 'utils/watch', + 'xstate2', ]); const V2_ONLY_FIRST_SEGMENTS = new Set([ @@ -112,6 +138,14 @@ export function checkSource(source, absFile) { if (!inSrc) continue; + const targetAbs = resolveIntraV2(specifier, absFile); + if (targetAbs !== undefined) { + const basesInternal = basesInternalViolation(absFile, stripTs(targetAbs), specifier); + if (basesInternal !== undefined) { + violations.push({ file: absFile, line, message: basesInternal }); + } + } + if (inHuman) { if (specifier.startsWith('#/')) { const first = specifier.slice(2).split('/')[0]; @@ -124,7 +158,6 @@ export function checkSource(source, absFile) { continue; } } - const targetAbs = resolveIntraV2(specifier, absFile); if (targetAbs !== undefined && !isInside(HUMAN_ROOT, targetAbs)) { violations.push({ file: absFile, @@ -132,6 +165,12 @@ export function checkSource(source, absFile) { message: `human must not import outside its kernel ('${specifier}') — human is the pure LLM/agent kernel: it never imports llm-adapter or v2 domains`, }); } + if (targetAbs !== undefined) { + const traitBoundary = traitBoundaryViolation(absFile, stripTs(targetAbs), specifier); + if (traitBoundary !== undefined) { + violations.push({ file: absFile, line, message: traitBoundary }); + } + } continue; } diff --git a/packages/agent-core-v2/src/_base/execEnv/decodeText.ts b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts index 2f0a1290842..62bae9b58d1 100644 --- a/packages/agent-core-v2/src/_base/execEnv/decodeText.ts +++ b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts @@ -1,5 +1,33 @@ export type TextDecodeErrors = 'strict' | 'replace' | 'ignore'; +export async function* readUtf8Lines( + source: AsyncIterable, + errors: TextDecodeErrors = 'strict', +): AsyncGenerator { + let pending: Buffer[] = []; + let offset = 0; + let pendingOffset = 0; + for await (const bytes of source) { + const chunk = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let start = 0; + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] !== 0x0a) continue; + const piece = chunk.subarray(start, i + 1); + const lineOffset = pending.length === 0 ? offset + start : pendingOffset; + const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]); + yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0); + pending = []; + start = i + 1; + } + if (start < chunk.length) { + if (pending.length === 0) pendingOffset = offset + start; + pending.push(Buffer.from(chunk.subarray(start))); + } + offset += chunk.length; + } + if (pending.length > 0) yield decodeTextWithErrors(Buffer.concat(pending), 'utf-8', errors, pendingOffset !== 0); +} + function isUtf8Continuation(byte: number): boolean { return byte >= 0x80 && byte <= 0xbf; } diff --git a/packages/agent-core-v2/src/_base/utils/fileMeta.ts b/packages/agent-core-v2/src/_base/utils/fileMeta.ts index 4f76835b0b7..4d3c9fcab88 100644 --- a/packages/agent-core-v2/src/_base/utils/fileMeta.ts +++ b/packages/agent-core-v2/src/_base/utils/fileMeta.ts @@ -65,6 +65,29 @@ export function guessMime(path: string, isBinary: boolean): string { return isBinary ? 'application/octet-stream' : 'text/plain'; } +const APPLICATION_TEXT_ALIASES: Readonly> = { + 'application/javascript': 'text/javascript', + 'application/x-javascript': 'text/javascript', + 'application/ecmascript': 'text/javascript', + 'application/yaml': 'text/yaml', + 'application/x-yaml': 'text/yaml', + 'application/sql': 'text/plain', + 'application/graphql': 'text/plain', + 'application/x-www-form-urlencoded': 'text/plain', +}; + +export function textExtensionForMime(mimeType: string): string | undefined { + const mime = mimeType.split(';')[0]!.trim().toLowerCase(); + if (mime === 'application/json' || mime.endsWith('+json')) return '.json'; + if (mime === 'application/xml' || mime.endsWith('+xml')) return '.xml'; + if (mime.endsWith('+yaml')) return '.yaml'; + if (mime === 'application/toml') return '.toml'; + if (mime === 'text/csv') return '.csv'; + const textMime = APPLICATION_TEXT_ALIASES[mime] ?? mime; + if (!textMime.startsWith('text/')) return undefined; + return Object.entries(EXT_TO_MIME).find(([, value]) => value === textMime)?.[0] ?? '.txt'; +} + const EXT_TO_LANGUAGE: Readonly> = { '.ts': 'typescript', '.tsx': 'typescriptreact', diff --git a/packages/agent-core-v2/src/agent/activityView/activityView.ts b/packages/agent-core-v2/src/agent/activityView/activityView.ts deleted file mode 100644 index c05d56ba23f..00000000000 --- a/packages/agent-core-v2/src/agent/activityView/activityView.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { PromptOrigin } from '#/agent/contextMemory/types'; -import type { TurnEndReason } from '#/agent/loop/turnEvents'; -import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2'; - -export type TurnPhase = 'running' | 'streaming' | 'tool_call' | 'retrying'; - -export interface ApprovalRef { - readonly approvalId: string; - readonly toolCallId?: string; - readonly since: number; -} - -export interface ToolCallRef { - readonly toolCallId: string; - readonly name: string; - readonly since: number; -} - -export interface ActivityRetryState { - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName?: string; - readonly statusCode?: number; -} - -export interface ActivityTurnState { - readonly turnId: number; - readonly origin: PromptOrigin; - readonly phase: TurnPhase; - readonly stream?: 'assistant' | 'thinking' | 'tool_call'; - readonly step: number; - readonly ending: boolean; - readonly endingReason?: 'aborted' | 'max_steps' | 'error'; - readonly retry?: ActivityRetryState; - readonly pendingApprovals: readonly ApprovalRef[]; - readonly activeToolCalls: readonly ToolCallRef[]; - readonly since: number; -} - -export interface ActivityLastTurnState { - readonly turnId: number; - readonly reason: TurnEndReason; - readonly durationMs?: number; - readonly at: number; -} - -export interface BackgroundRef { - readonly kind: string; - readonly id: string; - readonly since: number; -} - -export type ActivityViewLifecycle = 'ready' | 'disposed'; - -export interface AgentActivityState { - readonly lifecycle: ActivityViewLifecycle; - readonly turn?: ActivityTurnState; - readonly lastTurn?: ActivityLastTurnState; - readonly background: readonly BackgroundRef[]; -} - -export interface IAgentActivityView { - readonly _serviceBrand: undefined; - - state(): AgentActivityState; -} - -export const IAgentActivityView: ServiceIdentifier = - createDecorator('agentActivityView'); - -export class AgentActivityUpdated extends AgentEvent2 { - static override readonly type = 'agent.activity.updated'; - static override readonly observable = true; -} -export interface AgentActivityUpdated extends AgentActivityState { - readonly agentId: string; -} diff --git a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts deleted file mode 100644 index 85f49a8ed8d..00000000000 --- a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/state/state'; -import { IEventBus } from '#/app/event/eventBus'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { - AssistantDelta, - ThinkingDelta, - ToolCallDelta, - TurnStarted, - TurnStepStarted, - TurnStepCompleted, - TurnStepInterrupted, - TurnStepRetrying, -} from '#/agent/loop/turnEvents'; -import { TurnEnded, turnKey } from '#/agent/loop/turnOps'; -import { ToolCallStarted, ToolResultEvent } from '#/agent/toolExecutor/toolExecutorEvents'; -import { - PermissionApprovalRequested, - PermissionApprovalResolved, -} from '#/agent/toolApproval/toolApprovalService'; -import { TaskStarted, TaskTerminatedNotice } from '#/agent/task/taskOps'; -import { - CompactionCancelled, - CompactionCompleted, - CompactionStarted, -} from '#/agent/fullCompaction/compactionOps'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentTaskService } from '#/agent/task/task'; -import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; -import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; -import type { PromptOrigin } from '#/agent/contextMemory/types'; -import type { TurnEndReason } from '#/agent/loop/turnEvents'; -import { IEventDispatcher } from '#/state/eventDispatcher'; -import { ContextUndone } from '#/agent/undo/undoService'; - -import type { - ActivityLastTurnState, - ActivityRetryState, - ActivityTurnState, - ActivityViewLifecycle, - AgentActivityState, - ApprovalRef, - BackgroundRef, - ToolCallRef, - TurnPhase, -} from './activityView'; -import { AgentActivityUpdated, IAgentActivityView } from './activityView'; - -type EndingReason = NonNullable; -const FULL_COMPACTION_BACKGROUND_ID = 'full-compaction'; - -export const activityViewLifecycleKey = defineState( - 'activityView.lifecycle', - () => 'ready', -); -export const activityViewTurnKey = defineState( - 'activityView.turn', - () => undefined as MutableTurn | undefined, -); -export const activityViewLastTurnKey = defineState( - 'activityView.lastTurn', - () => undefined as ActivityLastTurnState | undefined, -); -export const activityViewBackgroundKey = defineState>( - 'activityView.background', - () => new Map(), -); -export const activityViewCurrentKey = defineState('activityView.current', () => ({ - lifecycle: 'ready', - background: [], -})); - -export class AgentActivityView extends Disposable implements IAgentActivityView { - declare readonly _serviceBrand: undefined; - - constructor( - @IEventBus private readonly eventBus: IEventBus, - @IAgentLoopService private readonly loop: IAgentLoopService, - @IAgentTaskService private readonly tasks: IAgentTaskService, - @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, - @IAgentStateService private readonly states: IAgentStateService, - @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - ) { - super(); - this.states.contributeState(activityViewLifecycleKey); - this.states.contributeState(activityViewTurnKey); - this.states.contributeState(activityViewLastTurnKey); - this.states.contributeState(activityViewBackgroundKey); - this.states.contributeState(activityViewCurrentKey); - this.seedFromLoop(); - this.seedFromTasks(); - this.seedFromFullCompaction(); - this._register( - this.dispatcher.hooks.onDidRestore.register('activityView', async (_ctx, next) => { - this.seedLastTurnFromWire(); - await next(); - }), - ); - - this._register(this.eventBus.subscribe(TurnStarted, (e) => this.onTurnStarted(e.turnId, e.origin))); - this._register(this.eventBus.subscribe(TurnStepStarted, (e) => this.onStepStarted(e.step))); - this._register(this.eventBus.subscribe(AssistantDelta, () => this.onDelta('assistant'))); - this._register(this.eventBus.subscribe(ThinkingDelta, () => this.onDelta('thinking'))); - this._register(this.eventBus.subscribe(ToolCallDelta, () => this.onDelta('tool_call'))); - this._register( - this.eventBus.subscribe(ToolCallStarted, (e) => this.onToolCallStarted(e.toolCallId, e.name)), - ); - this._register(this.eventBus.subscribe(ToolResultEvent, (e) => this.onToolResult(e.toolCallId))); - this._register( - this.eventBus.subscribe(TurnStepRetrying, (e) => { - this.mutateTurn((t) => { - t.phase = 'retrying'; - t.stream = undefined; - t.retry = { - failedAttempt: e.failedAttempt, - nextAttempt: e.nextAttempt, - maxAttempts: e.maxAttempts, - delayMs: e.delayMs, - errorName: e.errorName, - statusCode: e.statusCode, - }; - }); - }), - ); - this._register( - this.eventBus.subscribe(TurnStepCompleted, () => { - this.mutateTurn((t) => { - t.phase = 'running'; - t.stream = undefined; - t.retry = undefined; - }); - }), - ); - this._register( - this.eventBus.subscribe(TurnStepInterrupted, (e) => this.onStepInterrupted(e.turnId, e.reason)), - ); - this._register( - this.eventBus.subscribe(TurnEnded, (e) => this.onTurnEnded(e.turnId, e.reason)), - ); - this._register( - this.eventBus.subscribe(ContextUndone, (e) => this.onContextUndone(e.fromTurnId)), - ); - this._register( - this.eventBus.subscribe(PermissionApprovalRequested, (e) => - this.onApprovalRequested(e.id ?? e.toolCallId, e.toolCallId), - - ), - ); - this._register( - this.eventBus.subscribe(PermissionApprovalResolved, (e) => - this.onApprovalResolved(e.id ?? e.toolCallId), - - ), - ); - this._register( - this.eventBus.subscribe(TaskStarted, (e) => { - this.background.set(e.info.taskId, { - kind: e.info.kind, - id: e.info.taskId, - since: e.info.startedAt, - }); - this.publish(); - }), - ); - this._register( - this.eventBus.subscribe(TaskTerminatedNotice, (e) => { - if (this.background.delete(e.info.taskId)) this.publish(); - }), - ); - this._register( - this.eventBus.subscribe(CompactionStarted, () => { - this.background.set(FULL_COMPACTION_BACKGROUND_ID, { - kind: 'compaction', - id: FULL_COMPACTION_BACKGROUND_ID, - since: Date.now(), - }); - this.publish(); - }), - ); - this._register( - this.eventBus.subscribe(CompactionCompleted, () => { - this.onFullCompactionEnded(); - }), - ); - this._register( - this.eventBus.subscribe(CompactionCancelled, () => { - this.onFullCompactionEnded(); - }), - ); - } - - private get lifecycle(): ActivityViewLifecycle { - return this.states.get(activityViewLifecycleKey); - } - - private set lifecycle(value: ActivityViewLifecycle) { - this.states.set(activityViewLifecycleKey, value); - } - - private get turn(): MutableTurn | undefined { - return this.states.get(activityViewTurnKey); - } - - private set turn(value: MutableTurn | undefined) { - this.states.set(activityViewTurnKey, value); - } - - private get lastTurn(): ActivityLastTurnState | undefined { - return this.states.get(activityViewLastTurnKey); - } - - private set lastTurn(value: ActivityLastTurnState | undefined) { - this.states.set(activityViewLastTurnKey, value); - } - - private get background(): Map { - return this.states.get(activityViewBackgroundKey); - } - - private get current(): AgentActivityState { - return this.states.get(activityViewCurrentKey); - } - - private set current(value: AgentActivityState) { - this.states.set(activityViewCurrentKey, value); - } - - state(): AgentActivityState { - return this.current; - } - - override dispose(): void { - this.lifecycle = 'disposed'; - this.publish(); - super.dispose(); - } - - private seedFromLoop(): void { - const status = this.loop.status(); - if (status.state === 'running' && status.activeTurnId !== undefined) { - this.turn = new MutableTurn(status.activeTurnId, USER_PROMPT_ORIGIN); - this.publish(); - return; - } - this.seedLastTurnFromWire(); - } - - private seedLastTurnFromWire(): void { - if (this.turn !== undefined || this.lastTurn !== undefined) return; - const lastEnded = this.states.get(turnKey).lastEnded; - if (lastEnded === undefined) return; - this.lastTurn = { - turnId: lastEnded.turnId, - reason: lastEnded.reason, - durationMs: lastEnded.durationMs, - at: Date.now(), - }; - this.publish(); - } - - private seedFromTasks(): void { - for (const info of this.tasks.list(true)) { - this.background.set(info.taskId, { kind: info.kind, id: info.taskId, since: info.startedAt }); - } - if (this.background.size > 0) this.publish(); - } - - private seedFromFullCompaction(): void { - if (this.fullCompaction.compacting === null) return; - this.background.set(FULL_COMPACTION_BACKGROUND_ID, { - kind: 'compaction', - id: FULL_COMPACTION_BACKGROUND_ID, - since: Date.now(), - }); - this.publish(); - } - - private onFullCompactionEnded(): void { - if (this.background.delete(FULL_COMPACTION_BACKGROUND_ID)) this.publish(); - } - - private onTurnStarted(turnId: number, origin?: PromptOrigin): void { - this.turn = new MutableTurn(turnId, origin ?? USER_PROMPT_ORIGIN); - this.lastTurn = undefined; - this.publish(); - } - - private onTurnEnded(turnId: number, reason: TurnEndReason): void { - if (this.turn === undefined || this.turn.turnId !== turnId) { - this.lastTurn = { turnId, reason, at: Date.now() }; - this.publish(); - return; - } - this.lastTurn = { turnId, reason, durationMs: Date.now() - this.turn.since, at: Date.now() }; - this.turn = undefined; - this.publish(); - } - - private onContextUndone(fromTurnId: number | undefined): void { - const last = this.lastTurn; - if (last === undefined) return; - if (fromTurnId !== undefined && last.turnId < fromTurnId) return; - this.lastTurn = undefined; - this.publish(); - } - - private onStepStarted(step: number): void { - this.mutateTurn((t) => { - t.step = step; - t.phase = 'running'; - t.stream = undefined; - t.retry = undefined; - }); - } - - private onStepInterrupted(turnId: number, reason: string): void { - if (reason !== 'aborted' && reason !== 'max_steps' && reason !== 'error') return; - this.mutateTurn((t) => { - if (t.turnId !== turnId) return; - t.ending = true; - t.endingReason = reason; - }); - } - - private onDelta(stream: 'assistant' | 'thinking' | 'tool_call'): void { - this.mutateTurn((t) => { - t.phase = 'streaming'; - t.stream = stream; - t.retry = undefined; - }); - } - - private onToolCallStarted(toolCallId: string, name: string): void { - this.mutateTurn((t) => { - t.phase = 'tool_call'; - t.stream = undefined; - t.retry = undefined; - t.activeToolCalls.set(toolCallId, { toolCallId, name, since: Date.now() }); - }); - } - - private onToolResult(toolCallId: string): void { - this.mutateTurn((t) => { - t.activeToolCalls.delete(toolCallId); - t.phase = t.activeToolCalls.size === 0 ? 'running' : 'tool_call'; - t.stream = undefined; - t.retry = undefined; - }); - } - - private onApprovalRequested(approvalId: string, toolCallId: string): void { - this.mutateTurn((t) => { - t.pendingApprovals.set(approvalId, { approvalId, toolCallId, since: Date.now() }); - }); - } - - private onApprovalResolved(approvalId: string): void { - this.mutateTurn((t) => { - t.pendingApprovals.delete(approvalId); - }); - } - - private mutateTurn(mutate: (t: MutableTurn) => void): void { - if (this.turn === undefined) return; - mutate(this.turn); - this.publish(); - } - - private publish(): void { - const t = this.turn; - const next: AgentActivityState = { - lifecycle: this.lifecycle, - turn: t === undefined ? undefined : t.snapshot(), - lastTurn: this.lastTurn, - background: [...this.background.values()], - }; - if (activityEqual(this.current, next)) return; - this.current = next; - void this.dispatcher.dispatch( - new AgentActivityUpdated({ ...next, agentId: this.scopeContext.agentId }), - ); - } -} - -class MutableTurn { - phase: TurnPhase = 'running'; - stream: ActivityTurnState['stream']; - step = 0; - ending = false; - endingReason: EndingReason | undefined; - retry: ActivityRetryState | undefined; - readonly pendingApprovals = new Map(); - readonly activeToolCalls = new Map(); - readonly since = Date.now(); - - constructor( - readonly turnId: number, - readonly origin: PromptOrigin, - ) {} - - snapshot(): ActivityTurnState { - return { - turnId: this.turnId, - origin: this.origin, - phase: this.phase, - stream: this.stream, - step: this.step, - ending: this.ending, - endingReason: this.endingReason, - retry: this.retry, - pendingApprovals: [...this.pendingApprovals.values()], - activeToolCalls: [...this.activeToolCalls.values()], - since: this.since, - }; - } -} - -function activityEqual(a: AgentActivityState, b: AgentActivityState): boolean { - if (a.lifecycle !== b.lifecycle) return false; - if ((a.turn === undefined) !== (b.turn === undefined)) return false; - if (a.turn !== undefined && b.turn !== undefined) { - const ta = a.turn; - const tb = b.turn; - if ( - ta.turnId !== tb.turnId || - ta.phase !== tb.phase || - ta.stream !== tb.stream || - ta.step !== tb.step || - ta.ending !== tb.ending || - ta.endingReason !== tb.endingReason || - ta.pendingApprovals.length !== tb.pendingApprovals.length || - ta.activeToolCalls.length !== tb.activeToolCalls.length - ) { - return false; - } - if (ta.retry?.nextAttempt !== tb.retry?.nextAttempt) return false; - } - if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false; - if (a.lastTurn !== undefined && b.lastTurn !== undefined) { - if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) { - return false; - } - } - if (a.background.length !== b.background.length) return false; - for (let i = 0; i < a.background.length; i++) { - if (a.background[i]!.id !== b.background[i]!.id || a.background[i]!.kind !== b.background[i]!.kind) { - return false; - } - } - return true; -} - -registerScopedService( - LifecycleScope.Agent, - IAgentActivityView, - AgentActivityView, - ScopeActivation.OnScopeCreated, - 'activityView', -); diff --git a/packages/agent-core-v2/src/agent/actorService/agentActorService.ts b/packages/agent-core-v2/src/agent/actorService/agentActorService.ts index 45570f2fbef..93fe6b01d2b 100644 --- a/packages/agent-core-v2/src/agent/actorService/agentActorService.ts +++ b/packages/agent-core-v2/src/agent/actorService/agentActorService.ts @@ -1,4 +1,4 @@ -import { createActor, type ActorLogic, type AnyActorRef, type Snapshot } from 'xstate'; +import { createActor, type ActorLogic, type AnyActorRef, type Snapshot } from '#human/xstate2'; import { BugIndicatingError } from '#/_base/errors/errors'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 1e9f0da346a..1d3a2ec23d6 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -11,6 +11,7 @@ import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTok import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester'; import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; +import { runWithCredentialRecovery } from '#/llm-adapter/model/credential-recovery'; import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; import { ILogService } from '#/_base/log/log'; import { TurnStarted } from '#/agent/loop/turnEvents'; @@ -777,23 +778,31 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom const estimatedCompactionRequestTokens = this.requestTokens(messages); try { - const request = this.llmRequester.start( - { - messages, - maxOutputSize: effectiveMaxOutputSize, - model: compactionRequestModel, - source: { - type: 'operation', - turnId: active.originTurnId, - requestKind: 'full_compaction', - logFields: { droppedCount }, + const runRequest = async () => { + const request = this.llmRequester.start( + { + messages, + maxOutputSize: effectiveMaxOutputSize, + model: compactionRequestModel, + source: { + type: 'operation', + turnId: active.originTurnId, + requestKind: 'full_compaction', + logFields: { droppedCount }, + }, }, - }, - undefined, + undefined, + signal, + ); + active.trace = request.trace; + return request.result; + }; + const result = await runWithCredentialRecovery( + this.llmRequester.currentCredentials(), + runRequest, signal, ); - active.trace = request.trace; - attempt = collectSummary(await request.result); + attempt = collectSummary(result); break; } catch (error) { const isContextOverflow = this.shouldRecoverFromContextOverflow( diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts index fe6a543f630..d7897f363eb 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts @@ -1,5 +1,6 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { FinishReason } from '#human/llm/finish-reason'; +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; import type { ThinkingEffort } from '#human/llm/thinking'; import type { Message } from '#/llm-adapter/contract/message'; import type { StreamedMessagePart, ToolDescription as Tool } from '#human/llm/message'; @@ -60,6 +61,10 @@ export interface IAgentLLMRequesterService { prepareTurnConfig(turnId: number): PreparedTurnRequestConfig | undefined; + currentCredentials(): LlmCredentialProvider | undefined; + + credentialsForTurn(turnId: number): LlmCredentialProvider | undefined; + request( overrides?: AgentLLMRequestOverrides, onPart?: AgentLLMRequestPartHandler, diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 8766f0697e2..a39cdb0ead9 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -27,6 +27,7 @@ import { } from '#/llm-adapter/contract/errors'; import type { Message } from '#/llm-adapter/contract/message'; import { type ThinkingEffort } from '#human/llm/thinking'; +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; import { isToolCall, type StreamedMessagePart, type ToolDescription as Tool } from '#human/llm/message'; import { emptyUsage, inputTotal, type TokenUsage } from '#human/llm/usage'; import { ILogService, type LogContext } from '#/_base/log/log'; @@ -212,6 +213,17 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { return { thinkingEffort: config.resolved.thinkingLevel }; } + currentCredentials(): LlmCredentialProvider | undefined { + if (!this.profile.hasProvider()) return undefined; + return this.modelCatalog.get(this.profile.resolveModelContext().modelAlias).credentials; + } + + credentialsForTurn(turnId: number): LlmCredentialProvider | undefined { + if (!this.profile.hasProvider()) return undefined; + const resolved = this.turnConfigs.get(turnId)?.resolved ?? this.profile.resolveModelContext(); + return this.modelCatalog.get(resolved.modelAlias).credentials; + } + async request( overrides: AgentLLMRequestOverrides = {}, onPart: AgentLLMRequestPartHandler = noopOnPart, diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index b86bdf7ade9..e20e4d73eec 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -6,6 +6,22 @@ import type { FinishReason } from '#human/llm/finish-reason'; import type { TokenUsage } from '#human/llm/usage'; import type { Hooks } from '#/hooks'; import { LoopErrors } from './errors'; +import type { MachineEngineRetrySnapshot, MachineEngineToolCallSnapshot } from './machine/engine'; + +export interface AgentActivityTurnSnapshot { + readonly turnId: number; + readonly phase: 'running' | 'tool_call' | 'retrying'; + readonly step: number; + readonly ending: boolean; + readonly endingReason?: 'aborted'; + readonly retry?: MachineEngineRetrySnapshot; + readonly activeToolCalls: readonly MachineEngineToolCallSnapshot[]; + readonly since?: number; +} + +export interface AgentActivitySnapshot { + readonly turn?: AgentActivityTurnSnapshot; +} export type LoopErrorCode = (typeof LoopErrors.codes)[keyof typeof LoopErrors.codes]; @@ -83,7 +99,7 @@ export type LoopRunResult = export type TurnResult = LoopRunResult; export interface Turn { - readonly id: number; + readonly id?: number; readonly state?: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; readonly signal: AbortSignal; readonly ready: Promise; @@ -94,7 +110,7 @@ export interface Turn { export interface AgentLoopStatus { readonly state: 'idle' | 'running'; readonly activeTurnId?: number; - readonly pendingTurnIds: readonly number[]; + readonly pendingPromptIds: readonly string[]; readonly hasPendingRequests: boolean; readonly activeTraceId?: string; } @@ -130,10 +146,14 @@ export interface IAgentLoopService { cancel(turnId?: number, reason?: unknown): boolean; + cancelQueued(queueId: string, reason?: unknown): boolean; + cancelFromUser(turnId?: number): void; status(): AgentLoopStatus; + activitySnapshot(): AgentActivitySnapshot; + tryAcquireQuiescence(): IDisposable | undefined; settled(): Promise; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 3b4a1d6fde1..9aaa6c44785 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -45,6 +45,7 @@ import { IAgentLoopService, isMaxStepsExceededError, type AfterStepContext, + type AgentActivitySnapshot, type AgentLoopStatus, type LoopError, type LoopErrorContext, @@ -83,10 +84,6 @@ import { export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; -export const loopNextReservedTurnIdKey = defineState( - 'loop.nextReservedTurnId', - () => undefined as number | undefined, -); export const loopLastRequestTraceIdKey = defineState( 'loop.lastRequestTraceId', () => undefined as string | undefined, @@ -114,10 +111,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private readonly nudges: Nudge[] = []; private nudgeCursor = 0; private active: ActiveTurn | undefined; - private machineTurnUnbound = false; + private pendingMachineTurn: { readonly id: number; readonly queueItemId?: string } | undefined; private machineTurnSuppressed = false; - private unboundDrained: TurnReservation | undefined; - private readonly pendingMachineQueueIds = new Set(); private readonly settleWaiters: Array<() => void> = []; private quiescenceDepth = 0; private activeRequestTrace: LLMRequestTrace | undefined; @@ -137,19 +132,10 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ) { super(); this.states.contributeState(turnKey); - this.states.contributeState(loopNextReservedTurnIdKey); this.states.contributeState(loopLastRequestTraceIdKey); this.states.contributeState(loopDisposingKey); } - private get nextReservedTurnId(): number | undefined { - return this.states.get(loopNextReservedTurnIdKey); - } - - private set nextReservedTurnId(value: number | undefined) { - this.states.set(loopNextReservedTurnIdKey, value); - } - private get lastRequestTraceId(): string | undefined { return this.states.get(loopLastRequestTraceIdKey); } @@ -174,8 +160,10 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { toolExecutor: this.toolExecutor, toolInfos: this.toolRegistry.list(), maxAttemptsPerStep: this.config.get(LOOP_CONTROL_SECTION)?.maxAttemptsPerStep, + initialTurnId: this.states.get(turnKey).nextTurnId, trace: () => this.activeRequestTrace, toolTurnId: () => this.active?.id, + steerSignal: () => this.active?.steerController.signal, source: () => this.active === undefined ? undefined @@ -202,7 +190,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { for (const reservation of this.reservations.splice(0)) { this.settleReservationCancelled(reservation, reason); } - this.pendingMachineQueueIds.clear(); this.active?.turn.cancel(reason); this.engine?.stop(); const active = this.active; @@ -218,7 +205,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (this.disposing) throw abortError('Agent loop disposed'); const reservation = this.createReservation(prompt); this.reservations.push(reservation); - if (this.quiescenceDepth === 0) { + if (this.quiescenceDepth === 0 && this.active === undefined) { this.launchReservation(reservation); } return { turn: reservation.turn }; @@ -232,11 +219,13 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const id = prompt.promptId ?? randomUUID(); this.nudges.push({ contextMessage: message, + steer: true, bypassMaxSteps: false, turnScoped: false, onConsume: prompt.onMaterialize, onDrop: undefined, }); + active.steerController.abort(abortError('Steered by new input')); this.machineEngine().submit({ id, message: machineUserMessage(message) }); this.machineEngine().steer(id); return active.turn; @@ -270,23 +259,22 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private createReservation(prompt: LoopPromptSubmit): TurnReservation { - const id = this.reserveTurnId(); const controller = new AbortController(); const ready = createControlledPromise(); const result = createControlledPromise(); void ready.catch(() => undefined); const message = normalizePromptMessage(prompt); + let reservation: TurnReservation; const turn: MutableTurn = { - id, + id: undefined, state: 'queued', signal: controller.signal, ready, result, - cancel: (reason) => this.cancel(id, reason), + cancel: (reason) => this.cancelReservation(reservation, reason), }; - return { - id, - machineQueueId: prompt.promptId ?? `turn-${String(id)}`, + reservation = { + machineQueueId: prompt.promptId ?? randomUUID(), message, origin: message.origin ?? { kind: 'user' }, promptId: prompt.promptId, @@ -297,43 +285,77 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { result, turn, }; + return reservation; } private launchReservation(reservation: TurnReservation): void { if (reservation.cancelled || reservation.launched) return; reservation.launched = true; - this.pendingMachineQueueIds.add(reservation.machineQueueId); this.machineEngine().submit({ id: reservation.machineQueueId, message: machineUserMessage(reservation.message), }); } - private reserveTurnId(): number { - const modelNextId = this.states.get(turnKey).nextTurnId; - const id = Math.max(modelNextId, this.nextReservedTurnId ?? modelNextId); - this.nextReservedTurnId = id + 1; - return id; - } - status(): AgentLoopStatus { return { state: this.active === undefined ? 'idle' : 'running', activeTurnId: this.active?.id, - pendingTurnIds: this.reservations + pendingPromptIds: this.reservations .filter((reservation) => !reservation.cancelled) - .map((reservation) => reservation.id), + .map((reservation) => reservation.machineQueueId), hasPendingRequests: this.hasPendingRequests(), activeTraceId: this.activeRequestTrace?.traceId, }; } + activitySnapshot(): AgentActivitySnapshot { + const engine = this.engine; + if (engine === undefined) return {}; + const snapshot = engine.snapshot(); + const turn = snapshot.turn; + if (turn === undefined) return {}; + return { + turn: { + turnId: turn.turnId, + phase: turn.phase, + step: turn.step, + ending: snapshot.aborting, + endingReason: snapshot.aborting ? 'aborted' : undefined, + retry: turn.retry, + activeToolCalls: turn.activeToolCalls, + since: this.active?.startedAt, + }, + }; + } + cancel(turnId?: number, reason?: unknown): boolean { const cancellation = reason ?? userCancellationReason(); - return ( - this.cancelActiveTurn(turnId, cancellation) || - (turnId !== undefined && this.cancelQueuedTurn(turnId, cancellation)) + return this.cancelActiveTurn(turnId, cancellation); + } + + cancelQueued(queueId: string, reason?: unknown): boolean { + const reservation = this.reservations.find( + (entry) => entry.machineQueueId === queueId && !entry.cancelled, ); + if (reservation === undefined) return false; + return this.cancelReservation(reservation, reason); + } + + private cancelReservation(reservation: TurnReservation, reason?: unknown): boolean { + const cancellation = reason ?? userCancellationReason(); + if (this.active?.reservation === reservation) { + return this.cancelActiveTurn(undefined, cancellation); + } + if (reservation.cancelled) return false; + reservation.cancelled = true; + const index = this.reservations.indexOf(reservation); + if (index >= 0) this.reservations.splice(index, 1); + if (reservation.launched) { + this.machineEngine().cancelQueueItem(reservation.machineQueueId); + } + this.settleReservationCancelled(reservation, cancellation); + return true; } cancelFromUser(turnId?: number): void { @@ -353,7 +375,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.quiescenceDepth > 0 || this.active !== undefined || this.hasPendingRequests() || - this.machineTurnUnbound + this.pendingMachineTurn !== undefined ) { return undefined; } @@ -397,27 +419,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return true; } - private cancelQueuedTurn(turnId: number, cancellation: unknown): boolean { - const index = this.reservations.findIndex((entry) => entry.id === turnId); - if (index < 0) return false; - const reservation = this.reservations[index]!; - if (reservation.cancelled) return false; - reservation.cancelled = true; - void this.dispatcher.dispatch( - new TurnCancel({ - agentId: this.scopeContext.agentId, - turnId, - target: 'queued', - reason: cancelReasonFor(cancellation), - }), - ); - if (!reservation.launched) { - this.reservations.splice(index, 1); - } - this.settleReservationCancelled(reservation, cancellation); - return true; - } - private settleReservationCancelled(reservation: TurnReservation, cancellation: unknown): void { reservation.cancelled = true; reservation.controller.abort(cancellation); @@ -429,6 +430,69 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.maybeSettle(); } + private settleUnboundReservation( + pending: { readonly id: number; readonly queueItemId?: string }, + outcome: { readonly outcome: MachineTurnOutcome; readonly error?: unknown }, + ): void { + const active = this.active; + if (active !== undefined) { + active.afterChain = active.afterChain.then(() => { + this.settleUnboundReservation(pending, outcome); + }); + return; + } + if (pending.queueItemId === undefined) { + const seeded = this.nudges.slice(this.nudgeCursor).find( + (nudge) => !nudge.dropped && nudge.contextMessage !== undefined && nudge.contextMessage.content.length > 0, + ); + if (seeded === undefined) { + this.consumeDrainedNudges(); + return; + } + this.beginActiveTurn( + this.createSeededReservation(seeded.contextMessage as ContextMessage), + pending.id, + ); + const seededTurn = this.active; + if (seededTurn === undefined) return; + this.mirrorConsumedNudges(seededTurn); + this.endPreGateTurn(seededTurn, outcome); + return; + } + const index = this.reservations.findIndex( + (entry) => entry.machineQueueId === pending.queueItemId, + ); + if (index < 0) return; + const [reservation] = this.reservations.splice(index, 1); + if (reservation === undefined || reservation.cancelled) return; + this.beginActiveTurn(reservation, pending.id); + reservation.onMaterialize?.(); + this.materializeMessage(reservation.message); + const turn = this.active; + if (turn === undefined) return; + this.endPreGateTurn(turn, outcome); + } + + private endPreGateTurn( + turn: ActiveTurn, + outcome: { readonly outcome: MachineTurnOutcome; readonly error?: unknown }, + ): void { + if (outcome.outcome === 'aborted') { + const reason = turn.controller.signal.aborted + ? turn.controller.signal.reason + : abortError('Turn aborted'); + turn.controller.abort(reason); + turn.afterChain = turn.afterChain.then(() => + this.endTurn(turn, { type: 'cancelled', steps: 0, reason }), + ); + return; + } + const error = outcome.error ?? new Error2(ErrorCodes.INTERNAL, 'Turn ended before first step'); + turn.afterChain = turn.afterChain.then(() => + this.endTurn(turn, { type: 'failed', steps: 0, error }), + ); + } + hasPendingRequests(): boolean { return ( this.reservations.some((reservation) => !reservation.cancelled) || @@ -440,7 +504,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if ( this.active === undefined && !this.hasPendingRequests() && - !this.machineTurnUnbound + this.pendingMachineTurn === undefined ) { return Promise.resolve(); } @@ -452,7 +516,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private maybeSettle(): void { if ( this.active !== undefined || - this.machineTurnUnbound || + this.pendingMachineTurn !== undefined || this.hasPendingRequests() ) return; if (this.settleWaiters.length === 0) return; @@ -492,12 +556,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private async gate(machineSignal: AbortSignal): Promise { - - const active = this.active; if (active !== undefined) await active.afterChain; - if (this.machineTurnUnbound && !this.bindMachineTurn()) { - return { type: 'fail' }; + const pending = this.pendingMachineTurn; + if (pending !== undefined) { + this.pendingMachineTurn = undefined; + if (!this.bindMachineTurn(pending)) return { type: 'fail' }; } const turn = this.active; if (turn === undefined) return { type: 'fail' }; @@ -505,21 +569,28 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (turn.stopRequested) return { type: 'fail' }; if (turn.failedStep !== undefined) return { type: 'fail' }; const consumed = this.mirrorConsumedNudges(turn); + if ( + turn.steerController.signal.aborted && + !this.nudges.slice(this.nudgeCursor).some((nudge) => nudge.steer && !nudge.dropped) + ) { + turn.steerController = new AbortController(); + } if (turn.toolStopRequested && consumed.live === 0) return { type: 'fail' }; + const stepOrdinal = Math.max(this.engine?.currentStep() ?? 0, turn.steps + 1); const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; if ( maxSteps !== undefined && maxSteps > 0 && - turn.steps >= maxSteps && + stepOrdinal > maxSteps && !consumed.bypass ) { turn.maxStepsError = createMaxStepsExceededError(maxSteps); return { type: 'fail' }; } - turn.steps += 1; - turn.gatedSteps = turn.steps; + turn.steps = stepOrdinal; + turn.gatedSteps = stepOrdinal; const step: MachineStepState = { - number: turn.steps, + number: stepOrdinal, uuid: randomUUID(), signal: turn.controller.signal, contentAppended: false, @@ -543,8 +614,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { await this.hooks.onWillBeginStep.run({ turnId: turn.id, - step: step.number, - firstStepOfTurn: step.number === 1, + step: stepOrdinal, + firstStepOfTurn: stepOrdinal === 1, signal: step.signal, }); @@ -575,21 +646,38 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return { type: 'fail' }; } - private bindMachineTurn(): boolean { - this.machineTurnUnbound = false; - if (this.active !== undefined) return true; - const drained = this.unboundDrained; - this.unboundDrained = undefined; - if (drained !== undefined) { - const index = this.reservations.indexOf(drained); - if (index >= 0) this.reservations.splice(index, 1); - if (drained.cancelled) { + private bindMachineTurn(pending: { readonly id: number; readonly queueItemId?: string }): boolean { + if (this.active !== undefined) { + if (pending.queueItemId !== undefined) { + const stolen = this.reservations.find( + (entry) => entry.machineQueueId === pending.queueItemId && !entry.cancelled, + ); + if (stolen !== undefined) { + this.machineEngine().submit({ + id: stolen.machineQueueId, + message: machineUserMessage(stolen.message), + }); + } + } + return true; + } + if (pending.queueItemId !== undefined) { + const index = this.reservations.findIndex( + (entry) => entry.machineQueueId === pending.queueItemId, + ); + const reservation = index >= 0 ? this.reservations[index] : undefined; + if (reservation === undefined) { + this.machineTurnSuppressed = true; + return false; + } + this.reservations.splice(index, 1); + if (reservation.cancelled) { this.machineTurnSuppressed = true; return false; } - this.beginActiveTurn(drained.turn, drained.controller, drained); - drained.onMaterialize?.(); - this.materializeMessage(drained.message); + this.beginActiveTurn(reservation, pending.id); + reservation.onMaterialize?.(); + this.materializeMessage(reservation.message); return true; } const seeded = this.nudges.slice(this.nudgeCursor).find( @@ -599,26 +687,31 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.machineTurnSuppressed = true; return false; } - const message = seeded.contextMessage as ContextMessage; - const id = this.reserveTurnId(); + this.beginActiveTurn( + this.createSeededReservation(seeded.contextMessage as ContextMessage), + pending.id, + ); + return true; + } + + private createSeededReservation(message: ContextMessage): TurnReservation { const controller = new AbortController(); const ready = createControlledPromise(); const result = createControlledPromise(); void ready.catch(() => undefined); + let reservation: TurnReservation; const turn: MutableTurn = { - id, + id: undefined, state: 'queued', signal: controller.signal, ready, result, - cancel: (reason) => this.cancel(id, reason), + cancel: (reason) => this.cancelReservation(reservation, reason), }; - const origin = message.origin ?? { kind: 'user' }; - this.beginActiveTurn(turn, controller, { - id, - machineQueueId: `turn-${String(id)}`, + reservation = { + machineQueueId: randomUUID(), message, - origin, + origin: message.origin ?? { kind: 'user' }, promptId: message.id, onMaterialize: undefined, cancelled: false, @@ -626,21 +719,18 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ready, result, turn, - }); - return true; + }; + return reservation; } - private beginActiveTurn( - turn: MutableTurn, - controller: AbortController, - reservation: TurnReservation, - ): void { - - const id = reservation.id; + private beginActiveTurn(reservation: TurnReservation, id: number): void { + const turn = reservation.turn; + turn.id = id; const active: ActiveTurn = { id, reservation, - controller, + controller: reservation.controller, + steerController: new AbortController(), turn, startedAt: Date.now(), steps: 0, @@ -666,6 +756,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { protocol: undefined, }; this.active = active; + active.readyResolved = true; + reservation.ready.resolve(); active.mode = this.telemetry.getContext().mode; const { provider_type, protocol } = this.telemetry.getContext(); active.providerType = provider_type; @@ -679,6 +771,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { input: reservation.message.content, origin: reservation.origin, promptId: reservation.promptId, + turnId: id, }), ); turn.state = 'running'; @@ -708,7 +801,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.context.append(message); } - private mirrorConsumedNudges(turn: ActiveTurn): { readonly live: number; readonly bypass: boolean } { + private consumeDrainedNudges(): { readonly live: number; readonly bypass: boolean } { const engine = this.engine; if (engine === undefined) return { live: 0, bypass: false }; const notificationCount = engine.snapshot().notificationCount; @@ -728,39 +821,19 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } nudge.onConsume?.(); } - turn.nudgeCursor = this.nudgeCursor; return { live, bypass }; } - private reconcileDrainedQueueEntry(): void { - const engine = this.engine; - if (engine === undefined) return; - const queueIds = engine.snapshot().queueIds; - const drainedIds: string[] = []; - for (const id of this.pendingMachineQueueIds) { - if (!queueIds.includes(id)) drainedIds.push(id); - } - for (const id of drainedIds) { - this.pendingMachineQueueIds.delete(id); - const reservation = this.reservations.find((entry) => entry.machineQueueId === id); - if (reservation === undefined) continue; - if (this.active === undefined) { - this.unboundDrained = reservation; - } else { - this.pendingMachineQueueIds.add(reservation.machineQueueId); - this.machineEngine().submit({ - id: reservation.machineQueueId, - message: machineUserMessage(reservation.message), - }); - } - } + private mirrorConsumedNudges(turn: ActiveTurn): { readonly live: number; readonly bypass: boolean } { + const consumed = this.consumeDrainedNudges(); + turn.nudgeCursor = this.nudgeCursor; + return consumed; } private projectMachineEvent(event: MachineEngineEvent): void { switch (event.type) { case 'turnStarted': { - this.reconcileDrainedQueueEntry(); - this.machineTurnUnbound = true; + this.pendingMachineTurn = { id: event.machineTurnId, queueItemId: event.queueItemId }; this.machineTurnSuppressed = false; return; } @@ -772,6 +845,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.maybeSettle(); return; } + if (this.pendingMachineTurn !== undefined) { + const pending = this.pendingMachineTurn; + this.pendingMachineTurn = undefined; + this.machineTurnSuppressed = false; + this.settleUnboundReservation(pending, outcome); + this.maybeSettle(); + return; + } if (active === undefined) return; active.afterChain = active.afterChain.then(() => this.evaluateSettle(active, outcome)); return; @@ -943,6 +1024,16 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.machineEngine().abort(); return; } + case 'recovering': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined) return; + if (step !== undefined) { + this.closeFailedMachineStep(turn, step, 'error'); + } + turn.current = undefined; + return; + } case 'retrying': { const turn = this.active; const step = turn?.current; @@ -1319,7 +1410,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turn.interruptStep = undefined; if (turn.retryRequested) { turn.retryRequested = false; - this.machineEngine().resetHistory(historyFromContext(this.context.get()), turn.id - 1); + await this.machineEngine().resetHistory(historyFromContext(this.context.get())); this.machineEngine().notify(EMPTY_MACHINE_PROMPT); } return; @@ -1468,6 +1559,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.activeRequestTrace = undefined; this.lastRequestTraceId = undefined; reservation.result.resolve(result); + for (const pending of this.reservations) { + if (!pending.cancelled) this.launchReservation(pending); + } this.maybeSettle(); } @@ -1515,7 +1609,6 @@ type MutableTurn = { }; interface TurnReservation { - readonly id: number; readonly machineQueueId: string; readonly message: ContextMessage; readonly origin: PromptOrigin; @@ -1531,6 +1624,7 @@ interface TurnReservation { interface Nudge { readonly contextMessage?: ContextMessage; + readonly steer?: boolean; readonly bypassMaxSteps: boolean; readonly turnScoped: boolean; readonly onConsume?: () => void; @@ -1569,6 +1663,7 @@ interface ActiveTurn { readonly id: number; readonly reservation: TurnReservation; readonly controller: AbortController; + steerController: AbortController; readonly turn: MutableTurn; readonly startedAt: number; steps: number; diff --git a/packages/agent-core-v2/src/agent/loop/machine/engine.ts b/packages/agent-core-v2/src/agent/loop/machine/engine.ts index 51fa87b4bc8..d0df250cc06 100644 --- a/packages/agent-core-v2/src/agent/loop/machine/engine.ts +++ b/packages/agent-core-v2/src/agent/loop/machine/engine.ts @@ -6,12 +6,17 @@ import type { ToolInfo, ToolResult as AgentToolResult, ToolUpdate as AgentToolUp import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; import { createAgentMachine } from '#human/agent/machine'; import { createTurnMachine, type AssistantEntry, type HistoryMessage } from '#human/agent/turn'; +import { messageAppended, turnEnded } from '#human/agent/events'; +import { agentSlices, type AgentEventStore } from '#human/agent/slices'; +import { createEventStoreSync } from '#human/eventStore/eventStore'; +import { memoryJournal } from '#human/eventStore/journal'; import type { LlmErrorMessage } from '#human/llm/errors'; import type { FinishInfo } from '#human/llm/finish-reason'; import type { StreamedMessagePart, UserMessage } from '#human/llm/message'; import type { LlmModel } from '#human/llm/model'; -import { createLlmMachine } from '#human/llm/requester/machine'; import type { LlmRecovery, LlmRecoveryRecord } from '#human/llm/requester/recovery'; +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; +import { resolveMaxAttempts } from '#human/llm/requester/retry'; import type { ToolResult as MachineToolResult, ToolUpdate } from '#human/tool/executor'; import type { TokenUsage } from '#human/llm/usage'; import { createActor, type Subscription } from '#human/xstate2'; @@ -38,7 +43,7 @@ export type MachineEngineDelta = export type MachineTurnOutcome = 'done' | 'failed' | 'aborted'; export type MachineEngineEvent = - | { readonly type: 'turnStarted'; readonly machineTurnId: number } + | { readonly type: 'turnStarted'; readonly machineTurnId: number; readonly queueItemId?: string } | { readonly type: 'turnSettled'; readonly outcome: MachineTurnOutcome; @@ -105,15 +110,39 @@ export interface CreateMachineEngineOptions { readonly maxAttemptsPerStep?: number; readonly recovery?: LlmRecovery; readonly abortTimeoutMs?: number; + readonly initialTurnId?: number; readonly trace?: () => LLMRequestTrace | undefined; readonly source?: () => AgentLLMRequestSource | undefined; readonly toolTurnId?: () => number | undefined; + readonly steerSignal?: () => AbortSignal | undefined; readonly gate?: (signal: AbortSignal) => Promise; readonly onTrace?: (trace: LLMRequestTrace) => void; readonly onEvent?: (event: MachineEngineEvent) => void; readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; } +export interface MachineEngineRetrySnapshot { + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName?: string; + readonly statusCode?: number; +} + +export interface MachineEngineToolCallSnapshot { + readonly toolCallId: string; + readonly name: string; +} + +export interface MachineEngineTurnSnapshot { + readonly turnId: number; + readonly phase: 'running' | 'tool_call' | 'retrying'; + readonly step: number; + readonly retry?: MachineEngineRetrySnapshot; + readonly activeToolCalls: readonly MachineEngineToolCallSnapshot[]; +} + export interface MachineEngineSnapshot { readonly running: boolean; readonly aborting: boolean; @@ -123,6 +152,7 @@ export interface MachineEngineSnapshot { readonly notificationCount: number; readonly reminderCount: number; readonly backgroundCount: number; + readonly turn?: MachineEngineTurnSnapshot; } export interface MachineEngine { @@ -130,18 +160,33 @@ export interface MachineEngine { steer(id: string): void; notify(message: UserMessage): void; remind(key: string, message: UserMessage): void; + cancelQueueItem(id: string): void; abort(): void; - resetHistory(history: readonly HistoryMessage[], turnId: number): void; + resetHistory(history: readonly HistoryMessage[]): Promise; stop(): void; snapshot(): MachineEngineSnapshot; + currentStep(): number; lastFinish(): AgentLLMRequestFinish | undefined; readonly toolExtras: ReadonlyMap; handleToolProgress(toolCallId: string, update: AgentToolUpdate): void; } +interface TurnSnapshotLike { + readonly value: unknown; + readonly context: { + readonly steps: number; + readonly attempt: number; + readonly delayMs: number; + readonly pendingToolCalls: readonly { readonly id: string; readonly name: string }[]; + readonly outcomes: Record; + }; +} + interface MachineSnapshotLike { readonly value: unknown; + readonly children: Record; readonly context: { + readonly turnId: number; readonly queue: readonly { readonly id?: string }[]; readonly notifications: readonly unknown[]; readonly reminders: readonly unknown[]; @@ -195,6 +240,7 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin let currentStep = 0; let split = createDeltaSplitter(); let pendingFailure: { step: number; error: LlmErrorMessage } | undefined; + let lastRetry: MachineEngineRetrySnapshot | undefined; const publish = (event: MachineEngineEvent): void => { options.onEvent?.(event); @@ -208,6 +254,7 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin toolExecutor: options.toolExecutor, toolInfos: options.toolInfos, turnId: () => options.toolTurnId?.() ?? 0, + steerSignal: options.steerSignal, trace: options.trace, onToolCall: (payload) => { publish({ @@ -223,32 +270,57 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin publish({ type: 'toolBatchFailed', error }); }, }); + const current = (): LlmCredentialProvider | undefined => { + const source = options.source?.(); + return source?.type === 'turn' + ? options.llmRequester.credentialsForTurn(source.turnId) + : options.llmRequester.currentCredentials(); + }; + const credentials: LlmCredentialProvider = { + resolve: () => current()?.resolve(), + canRecover: (error) => current()?.canRecover?.(error) === true, + invalidate: () => current()?.invalidate?.(), + }; + const journal = memoryJournal(); + const initialTurnId = options.initialTurnId ?? 0; + if (initialTurnId > 0) { + void journal.append({ + type: turnEnded.type, + kind: 'event', + data: turnEnded({ turnId: initialTurnId - 1, outcome: 'done' }), + }); + } + const store: AgentEventStore = createEventStoreSync({ journal, slices: agentSlices }); const actor = createActor( createAgentMachine({ tools: tools.tools, - turnActor: createTurnMachine( - createLlmMachine({ - requester: requester.requester, - }), - { - retry: { maxAttemptsPerStep: options.maxAttemptsPerStep }, - recovery: options.recovery, - }, - ), + turnActor: createTurnMachine(requester.requester, { + retry: { maxAttemptsPerStep: options.maxAttemptsPerStep }, + recovery: options.recovery, + }), abortTimeoutMs: options.abortTimeoutMs, }), - { input: { request: { model: options.model, systemPrompt: options.systemPrompt } } }, + { + input: { + request: { model: options.model, systemPrompt: options.systemPrompt, credentials }, + store, + }, + }, ); const subscriptions: Subscription[] = [ actor.on('turn.started', (event) => { currentStep = 0; split = createDeltaSplitter(); pendingFailure = undefined; - publish({ type: 'turnStarted', machineTurnId: event.turnId }); + lastRetry = undefined; + publish({ type: 'turnStarted', machineTurnId: event.turnId, queueItemId: event.queueItemId }); + }), + actor.on('step.started', (event) => { + currentStep = event.step; }), actor.on('llm.sent', (event) => { - currentStep += 1; split = createDeltaSplitter(); + lastRetry = undefined; tools.beginBatch(); publish({ type: 'stepStarted', step: currentStep, recovery: event.recovery }); }), @@ -258,6 +330,14 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin }), actor.on('llm.retrying', (event) => { pendingFailure = undefined; + lastRetry = { + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + statusCode: event.statusCode, + }; publish({ type: 'retrying', step: currentStep, @@ -285,6 +365,7 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin }), actor.on('llm.done', (event) => { pendingFailure = undefined; + lastRetry = undefined; tools.beginBatch(event.entry.message.toolCalls); const finish = requester.lastFinish(); const meta = event.entry.meta; @@ -372,11 +453,26 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin remind: (key, message) => { actor.send({ type: 'input.remind', key, message }); }, + cancelQueueItem: (id) => { + actor.send({ type: 'input.cancel', id }); + }, abort: () => { actor.send({ type: 'input.abort' }); }, - resetHistory: (history, turnId) => { - actor.send({ type: 'context.reset', history, turnId }); + resetHistory: (history) => { + const journal = memoryJournal(); + for (const message of history) { + void journal.append({ type: messageAppended.type, kind: 'event', data: messageAppended({ message }) }); + } + const nextTurnId = (actor.getSnapshot() as unknown as MachineSnapshotLike).context.turnId; + if (nextTurnId > 0) { + void journal.append({ + type: turnEnded.type, + kind: 'event', + data: turnEnded({ turnId: nextTurnId - 1, outcome: 'done' }), + }); + } + return store.reset(journal); }, stop: () => { for (const subscription of subscriptions) subscription.unsubscribe(); @@ -385,6 +481,36 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin snapshot: () => { const snapshot = actor.getSnapshot() as unknown as MachineSnapshotLike; const value = snapshot.value; + const turnRef = snapshot.children['turn']; + let turn: MachineEngineTurnSnapshot | undefined; + if (turnRef !== undefined) { + const turnSnapshot = turnRef.getSnapshot(); + const turnValue = turnSnapshot.value; + const phase = + turnValue === 'retrying' + ? ('retrying' as const) + : typeof turnValue === 'object' && turnValue !== null && 'acting' in turnValue + ? ('tool_call' as const) + : ('running' as const); + const context = turnSnapshot.context; + turn = { + turnId: snapshot.context.turnId, + phase, + step: context.steps, + retry: + phase === 'retrying' + ? (lastRetry ?? { + failedAttempt: context.attempt - 1, + nextAttempt: context.attempt, + maxAttempts: resolveMaxAttempts({ maxAttemptsPerStep: options.maxAttemptsPerStep }), + delayMs: context.delayMs, + }) + : undefined, + activeToolCalls: context.pendingToolCalls + .filter((toolCall) => context.outcomes[toolCall.id] === undefined) + .map((toolCall) => ({ toolCallId: toolCall.id, name: toolCall.name })), + }; + } return { running: value === 'running' || (typeof value === 'object' && value !== null && 'running' in value), aborting: typeof value === 'object' && value !== null && 'running' in value && @@ -397,9 +523,11 @@ export function createMachineEngine(options: CreateMachineEngineOptions): Machin notificationCount: snapshot.context.notifications.length, reminderCount: snapshot.context.reminders.length, backgroundCount: Object.keys(snapshot.context.background).length, + turn, }; }, lastFinish: () => requester.lastFinish(), + currentStep: () => currentStep, toolExtras: tools.extras, handleToolProgress: (toolCallId, update) => { tools.handleProgress(toolCallId, update); diff --git a/packages/agent-core-v2/src/agent/loop/machine/tools.ts b/packages/agent-core-v2/src/agent/loop/machine/tools.ts index 42042810228..aafebbc29cb 100644 --- a/packages/agent-core-v2/src/agent/loop/machine/tools.ts +++ b/packages/agent-core-v2/src/agent/loop/machine/tools.ts @@ -34,6 +34,7 @@ export interface CreateMachineToolsOptions { readonly toolExecutor: IAgentToolExecutorService; readonly toolInfos: readonly ToolInfo[]; readonly turnId: () => number; + readonly steerSignal?: () => AbortSignal | undefined; readonly trace?: () => LLMRequestTrace | undefined; readonly onToolCall?: (payload: ToolCallStartedPayload) => void; readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; @@ -104,6 +105,7 @@ export function createMachineTools(options: CreateMachineToolsOptions): MachineT try { const stream = options.toolExecutor.execute(calls, { signal, + steerSignal: options.steerSignal?.(), turnId: options.turnId(), trace: options.trace?.(), onToolCall: options.onToolCall, diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index ebc6b8da94b..c624a2fc2ce 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -38,6 +38,7 @@ const turnPromptSchema = z.object({ input: z.custom(), origin: z.custom(), promptId: z.string().optional(), + turnId: z.number().optional(), }); export class TurnPrompt extends AgentEvent2> { @@ -50,6 +51,7 @@ export interface TurnPrompt { readonly input: readonly ContentPart[]; readonly origin: PromptOrigin; readonly promptId?: string; + readonly turnId?: number; } const turnSteerSchema = z.object(turnInputShape); @@ -143,9 +145,10 @@ export const turnKey = defineState( if (next !== s) return next; }) .on(TurnPrompt, (s, e) => { - const next = advanceTurnClock(s, s.nextTurnId + 1); + const assigned = e.turnId ?? s.nextTurnId; + const next = advanceTurnClock(s, assigned + 1); if (!isUndoAnchorOrigin(e.origin)) return next; - return { ...next, anchorTurnIds: [...s.anchorTurnIds, s.nextTurnId] }; + return { ...next, anchorTurnIds: [...s.anchorTurnIds, assigned] }; }) .on(TurnSteer, () => {}) .on(ContextUndo, (s, e) => { diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index 11bf08ca9cd..bddf19ee362 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -11,14 +11,13 @@ import { abortable } from '#/_base/utils/abort'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { sessionMediaOriginalsDir } from '#/agent/media/image-originals'; +import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentLoopService } from '#/agent/loop/loop'; import { createMcpAuthTool } from '#/agent/mcp/tools/auth'; import { createMcpTool } from '#/agent/mcp/tools/mcp'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle'; import type { McpServerEntry } from '#/mcpCore/connection-manager'; import { IAgentMcpService } from './mcp'; @@ -53,7 +52,6 @@ export class AgentMcpService extends Service implements IAgentMcpService { constructor( @ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle, - @ISessionContext private readonly sessionContext: ISessionContext, @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentLoopService loop: IAgentLoopService, @@ -62,6 +60,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly states: IAgentStateService, @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionMediaStore private readonly attachmentStore: ISessionMediaStore, ) { super(); this.states.contributeState(mcpDiscoveryKey); @@ -286,7 +285,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { const disposable = this._register( this.registry.register( createMcpTool(qualified, tool, client, { - originalsDir: sessionMediaOriginalsDir(this.sessionContext.sessionDir), + attachmentStore: this.attachmentStore, telemetry: this.telemetry, providerType: () => this.profile.getModelProviderType(), reconnect: (signal) => this.reconnectForToolCall(serverName, client, signal), diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 16627b8a4a0..aa0e0f675ba 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -1,18 +1,28 @@ +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; import { isDeepStrictEqual } from 'node:util'; import type { ContentPart } from '#human/llm/message'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; import type { ExecutableToolResult } from '#/tool/toolContract'; +import { textExtensionForMime } from '#/_base/utils/fileMeta'; -import { compressImageContentParts } from '#/agent/media/image-compress'; +import { compressImageContentParts, gateImageFormatParts } from '#/agent/media/image-compress'; import { buildUnsupportedImageNotice, isModelAcceptedImageMime, + parseImageDataUrl, + resolveEffectiveImageMime, + decodeBase64Prefix, } from '#/agent/media/image-format-policy'; import { persistOriginalImage } from '#/agent/media/image-originals'; +import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { buildDaemonFileUrl, mediaExtensionForMime } from '#/agent/media/mediaRef'; import type { MCPContentBlock, MCPToolResult } from '#/mcpCore/types'; export interface McpOutputOptions { + readonly signal?: AbortSignal; + readonly attachmentStore?: ISessionMediaStore; readonly originalsDir?: string; readonly telemetry?: ITelemetryService; readonly providerType?: string; @@ -20,6 +30,7 @@ export interface McpOutputOptions { export const MCP_MAX_BINARY_PART_BYTES = 10 * 1024 * 1024; const MCP_MAX_BINARY_PART_CHARS = Math.ceil((MCP_MAX_BINARY_PART_BYTES * 4) / 3); +const MCP_MAX_INLINE_NOTICES_CHARS = 4096; function binaryPartTooLargeNotice(kind: 'image' | 'audio' | 'video', urlLength: number): string { const approxMb = ((urlLength * 3) / 4 / (1024 * 1024)).toFixed(1); @@ -115,9 +126,43 @@ export async function mcpResultToExecutableOutput( qualifiedToolName: string, options: McpOutputOptions = {}, ): Promise { + options.signal?.throwIfAborted(); const converted: ContentPart[] = []; + const attachmentNotices: string[] = []; + const preservedUrls = new Set(); + let omittedAttachment = false; + const preserveInlineMedia = async (url: string): Promise => { + options.signal?.throwIfAborted(); + if (preservedUrls.has(url)) return; + const parsed = parseImageDataUrl(url); + if (parsed === null) return; + preservedUrls.add(url); + const mime = parsed.mimeType.startsWith('image/') + ? resolveEffectiveImageMime(parsed.mimeType, decodeBase64Prefix(parsed.base64)) + : parsed.mimeType; + attachmentNotices.push(await preserveAttachment(parsed.base64, mime, options)); + }; for (const block of result.content) { - converted.push(convertMCPContentBlock(block, options.providerType)); + options.signal?.throwIfAborted(); + const part = convertMCPContentBlock(block, options.providerType); + if (part.type === 'image_url' && options.attachmentStore !== undefined) await preserveInlineMedia(part.imageUrl.url); + if (part.type === 'audio_url') await preserveInlineMedia(part.audioUrl.url); + if (part.type === 'video_url') await preserveInlineMedia(part.videoUrl.url); + const gated = gateImageFormatParts([part], options.providerType); + converted.push(...gated); + if (part.type === 'image_url' && gated[0]?.type === 'text') { + omittedAttachment = true; + await preserveInlineMedia(part.imageUrl.url); + } + if (part.type === 'text' && block.type === 'resource' && + typeof block.resource?.blob === 'string' && typeof block.resource.text !== 'string') { + omittedAttachment = true; + attachmentNotices.push(await preserveAttachment( + block.resource.blob, + block.resource.mimeType ?? 'application/octet-stream', + options, + )); + } } const wrapped = wrapMediaOnly(converted, qualifiedToolName); @@ -150,30 +195,126 @@ export async function mcpResultToExecutableOutput( } const compressed = await compressImageContentParts(wrapped, { + signal: options.signal, telemetry: options.telemetry, telemetrySource: 'mcp_tool_result', providerType: options.providerType, annotate: { - persistOriginal: (bytes, mimeType) => - persistOriginalImage( + persistOriginal: async (bytes, mimeType) => { + if (options.attachmentStore !== undefined) { + const saved = await saveAttachment(bytes, mimeType, options.attachmentStore, options.signal); + attachmentNotices.push(attachmentNotice(saved, mimeType, bytes.length)); + return saved.reference; + } + return persistOriginalImage( bytes, mimeType, options.originalsDir === undefined ? {} : { dir: options.originalsDir }, - ), + ); + }, }, }); - const capped = applyBinaryPartCap(compressed.parts); - const output = collapseSingleText(capped.parts); - const note = compressed.captions.length > 0 ? compressed.captions.join('\n') : undefined; + const capped = await applyBinaryPartCap(compressed.parts, preserveInlineMedia); + const notices = await attachmentDetails( + [...compressed.captions, ...attachmentNotices, ...capped.notices], options, + ); + const parts = [...capped.parts]; + if (notices.content.length > 0) parts.push({ type: 'text', text: notices.content }); + const output = collapseSingleText(parts); const base = { output, - note, - truncated: capped.truncated ? true : undefined, - spill: capped.notices.length > 0 ? { suffix: capped.notices.join('\n') } : undefined, + truncated: capped.truncated || omittedAttachment ? true : undefined, + spill: notices.suffix.length > 0 ? { suffix: notices.suffix } : undefined, }; return result.isError ? { ...base, isError: true } : base; } +async function attachmentDetails( + notices: readonly string[], + options: McpOutputOptions, +): Promise<{ readonly content: string; readonly suffix: string }> { + options.signal?.throwIfAborted(); + const content = [...new Set(notices)].join('\n'); + if (content.length <= MCP_MAX_INLINE_NOTICES_CHARS) return { content, suffix: content }; + try { + if (options.attachmentStore === undefined) throw new Error('Session attachment storage is unavailable'); + const saved = await saveAttachment(Buffer.from(content, 'utf8'), 'text/plain', options.attachmentStore, options.signal); + const pointer = [ + ...(saved.path === undefined ? [] : [`MCP attachment details saved at: ${JSON.stringify(saved.path)}`]), + `Attachment details reference: ${JSON.stringify(saved.reference)}`, + `Session-relative attachment details: ${JSON.stringify(saved.relativePath)}`, + 'Pass the attachment details reference to Read to retrieve all original attachment references and compression details in the current session.', + ].join('\n'); + return { content: pointer, suffix: pointer }; + } catch { + options.signal?.throwIfAborted(); + const suffix = 'The complete MCP attachment list could not be saved separately. Attachment details are included in the tool output and may be truncated. Do not repeat the MCP call automatically.'; + return { content: `${content}\n${suffix}`, suffix }; + } +} + +async function preserveAttachment( + base64: string, + mimeType: string, + options: McpOutputOptions, +): Promise { + options.signal?.throwIfAborted(); + try { + if (options.attachmentStore === undefined) throw new Error('Session attachment storage is unavailable'); + const compact = base64.replaceAll(/\s/g, ''); + const bytes = Buffer.from(compact, 'base64'); + const canonical = bytes.toString('base64'); + if (canonical !== compact && canonical.replace(/=+$/, '') !== compact) { + throw new Error('Invalid base64 attachment'); + } + const saved = await saveAttachment(bytes, mimeType, options.attachmentStore, options.signal); + return attachmentNotice(saved, mimeType, bytes.length); + } catch (error) { + options.signal?.throwIfAborted(); + return `Original attachment could not be saved (${JSON.stringify(mimeType)}): ${error instanceof Error ? error.message : String(error)}. No readable original path is available; original attachment preservation is incomplete. Do not repeat the MCP call automatically.`; + } +} + +interface SavedAttachment { + readonly path?: string; + readonly reference: string; + readonly relativePath: string; +} + +function attachmentNotice(saved: SavedAttachment, mimeType: string, size: number): string { + return [ + ...(saved.path === undefined ? [] : [`Original attachment saved at: ${JSON.stringify(saved.path)}`]), + `Attachment reference: ${JSON.stringify(saved.reference)}`, + `Session-relative attachment: ${JSON.stringify(saved.relativePath)}`, + `MIME: ${JSON.stringify(mimeType)}; size: ${String(size)} bytes. Pass the attachment reference to Read or ReadMediaFile in the current session. For other binary formats, Read reports the resolved local path for a converter.`, + ].join('\n'); +} + +async function saveAttachment( + bytes: Uint8Array, + mimeType: string, + store: ISessionMediaStore, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const mime = mimeType.split(';')[0]!.trim().toLowerCase(); + const hash = createHash('sha256').update(mime).update('\0').update(bytes).digest('hex'); + const ext = mime === 'application/pdf' ? '.pdf' + : mime === 'image/svg+xml' ? (bytes[0] === 0x1f && bytes[1] === 0x8b ? '.svgz' : '.svg') + : textExtensionForMime(mime) ?? mediaExtensionForMime(mime) ?? '.bin'; + const fileId = `f_mcp_${hash}`; + const path = await store.materialize({ + fileId, + size: bytes.length, + name: `attachment${ext}`, + mimeType: mime, + stream: () => Readable.from([bytes]), + signal, + }); + signal?.throwIfAborted(); + return { path, reference: buildDaemonFileUrl(fileId), relativePath: `media/${fileId}${ext}` }; +} + function parseComparableJson(text: string): unknown { return JSON.parse(text, (_key: string, value: unknown, context?: { source?: string }) => { if (typeof value === 'number' && context?.source !== JSON.stringify(value)) { @@ -226,11 +367,14 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string) ]; } -function applyBinaryPartCap(parts: readonly ContentPart[]): { +async function applyBinaryPartCap( + parts: readonly ContentPart[], + preserve: (url: string) => Promise, +): Promise<{ readonly parts: ContentPart[]; readonly truncated: boolean; readonly notices: string[]; -} { +}> { let truncated = false; const out: ContentPart[] = []; const notices: string[] = []; @@ -248,6 +392,7 @@ function applyBinaryPartCap(parts: readonly ContentPart[]): { ? part.audioUrl.url : part.videoUrl.url; if (url.length > MCP_MAX_BINARY_PART_CHARS) { + await preserve(url); const kind = part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video'; const notice = binaryPartTooLargeNotice(kind, url.length); diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts index 974b6fbd70e..e8b2b7ffe7e 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -4,7 +4,7 @@ import { Error2, ErrorCodes, toErrorMessage } from '#/errors'; import { isAbortError } from '#/_base/utils/abort'; import type { ExecutableTool, ExecutableToolContext } from '#/tool/toolContract'; -import { mcpResultToExecutableOutput } from '#/agent/mcp/output'; +import { mcpResultToExecutableOutput, type McpOutputOptions } from '#/agent/mcp/output'; import type { MCPClient, MCPToolResult } from '#/mcpCore/types'; import { isMcpConnectionClosedError, @@ -14,6 +14,7 @@ import { } from '#/mcpCore/client-shared'; interface McpToolOptions { + readonly attachmentStore?: McpOutputOptions['attachmentStore']; readonly originalsDir?: string; readonly telemetry?: ITelemetryService; readonly providerType?: () => string | undefined; @@ -51,6 +52,8 @@ export function createMcpTool( result = await retryAfterReconnect(error, client, args, context, options, callTool); } return mcpResultToExecutableOutput(result, qualifiedName, { + signal: context.signal, + attachmentStore: options.attachmentStore, originalsDir: options.originalsDir, telemetry: options.telemetry, providerType: options.providerType?.(), diff --git a/packages/agent-core-v2/src/agent/media/image-compress.ts b/packages/agent-core-v2/src/agent/media/image-compress.ts index 552ff58c586..0c877306f50 100644 --- a/packages/agent-core-v2/src/agent/media/image-compress.ts +++ b/packages/agent-core-v2/src/agent/media/image-compress.ts @@ -327,16 +327,20 @@ export async function compressImageContentParts( options: CompressImageOptions & { readonly annotate?: CompressAnnotateOptions; readonly providerType?: string; + readonly signal?: AbortSignal; } = {}, ): Promise { - const { annotate, providerType, ...compressOptions } = options; + const { annotate, providerType, signal, ...compressOptions } = options; + signal?.throwIfAborted(); const out: ContentPart[] = []; const captions: string[] = []; for (const part of gateImageFormatParts(parts, providerType)) { + signal?.throwIfAborted(); if (part.type === 'image_url') { const parsed = parseImageDataUrl(part.imageUrl.url); if (parsed !== null) { const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, compressOptions); + signal?.throwIfAborted(); if (result.changed) { if (annotate !== undefined) { let originalPath: string | null = null; @@ -347,6 +351,7 @@ export async function compressImageContentParts( parsed.mimeType, ); } catch { + signal?.throwIfAborted(); originalPath = null; } } diff --git a/packages/agent-core-v2/src/agent/media/mediaResolverService.ts b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts index b1781099778..c3f3ac767ea 100644 --- a/packages/agent-core-v2/src/agent/media/mediaResolverService.ts +++ b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts @@ -9,6 +9,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { Message } from '#/llm-adapter/contract/message'; import type { ContentPart } from '#human/llm/message'; import type { ModelRequester } from '#/llm-adapter/model/model-requester'; +import { runWithCredentialRecovery } from '#/llm-adapter/model/credential-recovery'; import { IBlobStore } from '#/persistence/interface/blobStore'; import { detectFileType, MEDIA_SNIFF_BYTES } from './file-type'; @@ -271,7 +272,11 @@ export class AgentMediaResolverService implements IAgentMediaResolverService { } try { - const uploaded = await uploader({ data: bytes, mimeType, filename }, { signal }); + const uploaded = await runWithCredentialRecovery( + requester.model.credentials, + () => uploader({ data: bytes, mimeType, filename }, { signal }), + signal, + ); const llmFileId = uploaded.videoUrl.id ?? msFileIdFromUrl(uploaded.videoUrl.url); if (llmFileId !== undefined) await this.writeCachedUpload(cacheKey, llmFileId); return { part: uploaded, memoize: true }; diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index 12f49e90d1f..7d13829d016 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -9,6 +9,7 @@ import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; import { type ModelRequester } from '#/llm-adapter/model/model-requester'; +import { runWithCredentialRecovery } from '#/llm-adapter/model/credential-recovery'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -18,6 +19,7 @@ import { extendWorkspaceWithSkillRoots } from '#/tool/path-access'; import { IAgentMediaToolsRegistrar } from './mediaTools'; import { createVideoUploader, registerMediaTools } from './registerMediaTools'; +import { ISessionMediaStore } from './sessionMediaStore'; export const mediaRegisteredKeyKey = defineState( 'media.registeredKey', @@ -39,6 +41,7 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentStateService private readonly states: IAgentStateService, @ISessionSkillCatalog private readonly skillCatalog?: ISessionSkillCatalog, + @ISessionMediaStore private readonly attachmentStore?: ISessionMediaStore, ) { super(); this.states.contributeState(mediaRegisteredKeyKey); @@ -68,7 +71,8 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool private refresh(): void { const capabilities = this.profile.getModelCapabilities(); const modelAlias = this.profile.getModel(); - if (!this.runtime.isAvailable(['fs'])) { + const hasRuntimeFs = this.runtime.isAvailable(['fs']); + if (!hasRuntimeFs && this.attachmentStore === undefined) { const key = [ modelAlias, String(capabilities.image_in), @@ -81,8 +85,8 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool this.registration = undefined; return; } - const inspected = this.runtime.inspect(); - const identityKey = [ + const inspected = hasRuntimeFs ? this.runtime.inspect() : undefined; + const identityKey = inspected === undefined ? 'session-attachments' : [ inspected.identity.workspaceId, inspected.identity.runtimeId, inspected.identity.generation, @@ -95,9 +99,9 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool String(capabilities.image_in), String(capabilities.video_in), identityKey, - inspected.status, - inspected.environment.pathClass, - String(inspected.capabilities.has('fs')), + inspected?.status, + inspected?.environment.pathClass, + String(hasRuntimeFs), ].join('|'); if (key === this.registeredKey) return; this.registeredKey = key; @@ -105,7 +109,7 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool const workspaceCtx = this.workspaceCtx; const skillCatalog = this.skillCatalog; const runtime = this.runtime; - const pathClass = inspected.environment.pathClass; + const pathClass = inspected?.environment.pathClass; let requester: ModelRequester | undefined; if (model !== undefined) { try { @@ -114,7 +118,16 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool requester = undefined; } } + const uploader = createVideoUploader(requester, { + client: this.telemetry, + props: { + model: modelAlias, + provider_type: model?.providerType ?? model?.protocol, + protocol: model?.protocol, + }, + }); this.registration = registerMediaTools(this.toolRegistry, { + attachmentStore: this.attachmentStore, runtime, workspace: { get workspaceDir() { @@ -129,14 +142,15 @@ export class AgentMediaToolsRegistrar extends Service implements IAgentMediaTool }, }, capabilities, - videoUploader: createVideoUploader(requester, { - client: this.telemetry, - props: { - model: modelAlias, - provider_type: model?.providerType ?? model?.protocol, - protocol: model?.protocol, - }, - }), + videoUploader: + uploader === undefined || requester === undefined + ? undefined + : (input, options) => + runWithCredentialRecovery( + requester.model.credentials, + () => uploader(input, options), + options?.signal, + ), inlineVideoSupported: model?.protocol !== 'openai' && model?.protocol !== 'openai_responses', providerType: model?.providerType, telemetry: this.telemetry, diff --git a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts index 6254ad011df..b5f9d048b73 100644 --- a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts +++ b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts @@ -2,6 +2,7 @@ import type { ModelCapability } from '#human/llm/capability'; import type { ModelRequester } from '#/llm-adapter/model/model-requester'; import type { VideoUploadEvent } from '#/app/telemetry/events'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ISessionMediaStore } from './sessionMediaStore'; import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import type { WorkspaceConfig } from '#/tool/path-access'; @@ -11,6 +12,7 @@ import { ReadMediaFileTool } from '#/agent/tools/read-media-file/readMediaFileTo import type { VideoUploader } from '#/agent/tools/read-media-file/read-media-file'; export interface RegisterMediaToolsDeps { + readonly attachmentStore?: ISessionMediaStore; readonly runtime: IAgentRuntimeService; readonly workspace: WorkspaceConfig; readonly capabilities: ModelCapability; @@ -25,7 +27,7 @@ export function registerMediaTools( deps: RegisterMediaToolsDeps, ): IDisposable { if ( - !deps.runtime.isAvailable(['fs']) || + (!deps.runtime.isAvailable(['fs']) && deps.attachmentStore === undefined) || (!deps.capabilities.image_in && !deps.capabilities.video_in) ) { return toDisposable(() => {}); @@ -39,6 +41,7 @@ export function registerMediaTools( deps.telemetry, deps.inlineVideoSupported, deps.providerType, + deps.attachmentStore, ), ); } diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts index 6bf4cd2af9e..20dbfc2fdbf 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts @@ -104,6 +104,15 @@ const DD_SAFE_DEVICE_TARGETS: ReadonlySet = new Set([ '/dev/stderr', ]); +const RM_SAFE_TEMP_ROOTS: readonly string[] = ['/tmp', '/temp']; + +function isSafeTempRmOperand(operand: string): boolean { + for (const segment of operand.split('/')) { + if (segment === '..') return false; + } + return RM_SAFE_TEMP_ROOTS.some((root) => operand === root || operand.startsWith(`${root}/`)); +} + type DangerousVerdict = | { readonly kind: 'dangerous'; readonly command: string } | { readonly kind: 'unanalyzable' }; @@ -273,8 +282,17 @@ function analyzeInvocation( if (name === 'rm') { let recursive = false; let force = false; + const operands: string[] = []; + let optionsEnded = false; for (const arg of args) { - if (arg === '--') break; + if (!optionsEnded && arg === '--') { + optionsEnded = true; + continue; + } + if (optionsEnded) { + operands.push(arg); + continue; + } if (arg === '--recursive') { recursive = true; } else if (arg === '--force') { @@ -282,9 +300,16 @@ function analyzeInvocation( } else if (/^-[a-zA-Z]+$/.test(arg)) { if (/[rR]/.test(arg)) recursive = true; if (arg.includes('f')) force = true; + } else { + operands.push(arg); + } + } + if (recursive && force) { + if (!dropped && operands.length > 0 && operands.every(isSafeTempRmOperand)) { + return undefined; } + return { kind: 'dangerous', command: 'rm -rf' }; } - if (recursive && force) return { kind: 'dangerous', command: 'rm -rf' }; return dropped ? { kind: 'unanalyzable' } : undefined; } return undefined; diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index ff6fb115184..5c15414b756 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -52,7 +52,6 @@ export interface PromptQueueSnapshot { export interface PromptPayload { readonly input: readonly ContentPart[]; - readonly disabledTools?: readonly string[]; readonly promptId?: string; } diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 8009590e629..e89cd4fead3 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -20,7 +20,6 @@ import { IAgentReminderService } from '#/features/reminder/reminderService'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IFileService } from '#/app/file/fileService'; import type { ContentPart } from '#human/llm/message'; import { IEventService } from '#/app/event/event'; @@ -264,7 +263,6 @@ export class AgentPromptService implements IAgentPromptService { @IInstantiationService private readonly instantiation: IInstantiationService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, - @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentProfileService private readonly profile: IAgentProfileService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentStateService private readonly states: IAgentStateService, @@ -358,16 +356,6 @@ export class AgentPromptService implements IAgentPromptService { async submit(payload: PromptPayload): Promise { const reservation = this[promptAdmission](payload.promptId); try { - if (payload.disabledTools !== undefined) { - try { - await this.toolPolicy.setSessionDisabledTools(payload.disabledTools); - } catch (error) { - throw new Error2( - ErrorCodes.REQUEST_INVALID, - error instanceof Error ? error.message : String(error), - ); - } - } await this.updatePromptMetadata(promptMetadataTextFromContentParts(payload.input)); const handle = await reservation.submit({ role: 'user', @@ -376,8 +364,7 @@ export class AgentPromptService implements IAgentPromptService { origin: { kind: 'user' }, }); if (handle.state === 'pending') return undefined; - const turn = await handle.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; + return await launchedTurnId(handle.launched); } finally { reservation.dispose(); } @@ -392,13 +379,11 @@ export class AgentPromptService implements IAgentPromptService { toolCalls: [], } }); if (queued.state !== 'pending') { - const turn = await queued.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; + return launchedTurnId(queued.launched); } try { const [steered] = await this.steer([queued.id]); - const turn = await steered?.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; + return await launchedTurnId(steered?.launched ?? Promise.resolve(undefined)); } catch (error) { if (isError2(error) && error.code === ErrorCodes.PROMPT_NOT_FOUND) return undefined; throw error; @@ -483,7 +468,7 @@ export class AgentPromptService implements IAgentPromptService { } abort(promptId: string, reason: Error = userCancellationReason()): boolean { - if (this.active?.id === promptId) { this.loop.cancel(this.active.turn.id, reason); return true; } + if (this.active?.id === promptId) { this.active.turn.cancel(reason); return true; } const index = this.pending.findIndex((item) => item.id === promptId); if (index < 0) throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, `prompt ${promptId} not found`); const [item] = this.pending.splice(index, 1) as [Record]; @@ -652,6 +637,12 @@ export class AgentPromptService implements IAgentPromptService { } function snapshot(item: Record): PromptSnapshot { return { id: item.id, userMessageId: item.userMessageId, createdAt: item.createdAt, state: item.state, message: item.message }; } +async function launchedTurnId(launched: Promise): Promise { + const turn = await launched; + if (turn === undefined) return undefined; + await turn.ready.catch(() => undefined); + return turn.id === undefined ? undefined : { turn_id: turn.id }; +} function deferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 631f417afc6..2a362988b06 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -85,6 +85,7 @@ export interface IAgentTaskService { ): Promise; readOutput(taskId: string, tail?: number): Promise; suppressTerminalNotification(taskId: string): Promise; + suppressAllTerminalNotifications(): Promise; markTasksDeliveredViaWait(tasks: readonly AgentTaskWaitDelivery[]): void; detach(taskId: string): AgentTaskInfo | undefined; stop(taskId: string, reason?: string): Promise; diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 5bea340a513..69e4254e18f 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -14,7 +14,7 @@ import { } from '#/_base/utils/abort'; import { setClampedTimeout } from '#/_base/utils/timer'; import { escapeXml, escapeXmlAttr, escapeXmlTags } from '#/_base/utils/xml-escape'; -import { IEventBus } from '#/app/event/eventBus'; +import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; import { Error2, ErrorCodes } from '#/errors'; import { z } from 'zod'; import { @@ -205,6 +205,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { declare readonly _serviceBrand: undefined; private readonly tasks = new Map(); + private exitSuppressionArmed = false; private readonly buildingNotificationKeys = new Set(); private readonly pendingNotificationRequests = new Map(); private readonly persistence: AgentTaskPersistence; @@ -220,6 +221,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITaskService private readonly taskService: ITaskService, @IEventBus private readonly eventBus: IEventBus, + @ISessionEventBus private readonly sessionEventBus: ISessionEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentReminderService private readonly reminder: IAgentReminderService, @IAgentLoopService private readonly loop: IAgentLoopService, @@ -778,21 +780,16 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { return results.filter((info): info is AgentTaskInfo => info !== undefined); } + async suppressAllTerminalNotifications(): Promise { + this.exitSuppressionArmed = true; + for (const [, request] of Array.from(this.pendingNotificationRequests)) { + request.drop(); + } + } + async stopAllOnExit(reason: string): Promise { + await this.suppressAllTerminalNotifications(); if (this.keepAliveOnExit()) return []; - const active = this.list(true); - await Promise.allSettled( - active - .filter((task) => task.detached === true) - .map((task) => - this.suppressTerminalNotification(task.taskId).catch((error: unknown) => { - this.log.error('terminal notification suppression failed', { - taskId: task.taskId, - error, - }); - }), - ), - ); return this.stopAll(reason); } @@ -829,6 +826,14 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { return resolveAgentTaskConfig(this.config)?.keepAliveOnExit === true; } + private lifecycleActive(): boolean { + return this.sessionEventBus.isAgentActive(this.scopeContext.agentContext); + } + + private marksTerminalNotificationSuppressed(entry: ManagedTask): boolean { + return this.exitSuppressionArmed && !this.keepAliveOnExit() && this.isDetached(entry); + } + async wait( taskId: string, timeoutMs = 30_000, @@ -1030,12 +1035,22 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { entry.timeoutHandle = undefined; } const foregroundRelease = entry.foregroundRelease; + if (this.marksTerminalNotificationSuppressed(entry)) { + entry.terminalNotificationSuppressed = true; + } if (entry.outputPersistStarted) { await this.persistLive(entry); } else { entry.pendingOutput = []; entry.pendingOutputBytes = 0; } + if ( + this.marksTerminalNotificationSuppressed(entry) && + entry.terminalNotificationSuppressed !== true + ) { + entry.terminalNotificationSuppressed = true; + await this.persistLive(entry); + } this.fireTerminalEffects(entry); foregroundRelease?.resolve('terminal'); this.resolveWaiters(entry); @@ -1061,9 +1076,11 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private recordTaskStarted(info: AgentTaskInfo): void { - void this.dispatcher.dispatch( - new TaskStarted({ agentId: this.scopeContext.agentId, info }), - ); + if (this.lifecycleActive()) { + void this.dispatcher.dispatch( + new TaskStarted({ agentId: this.scopeContext.agentId, info }), + ); + } this.telemetry.track2('background_task_created', { task_id: info.taskId, kind: info.kind === 'process' ? 'bash' : info.kind, @@ -1071,9 +1088,11 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private recordTaskTerminated(info: AgentTaskInfo, outputTail?: string): void { - void this.dispatcher.dispatch( - new TaskTerminated({ agentId: this.scopeContext.agentId, info, outputTail }), - ); + if (this.lifecycleActive()) { + void this.dispatcher.dispatch( + new TaskTerminated({ agentId: this.scopeContext.agentId, info, outputTail }), + ); + } this.telemetry.track2('background_task_completed', { task_id: info.taskId, kind: info.kind, @@ -1083,8 +1102,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private async notifyAgentTask(info: AgentTaskInfo): Promise { + if (!this.lifecycleActive()) return; const context = await this.buildAgentTaskNotificationContext(info); if (context === undefined) return; + if (!this.lifecycleActive() || this.isTerminalNotificationSuppressed(info.taskId)) return; const key = notificationKey(context.origin); if (this.deliveredNotificationKeys.has(key)) return; const handle = this.loop.notify({ @@ -1275,6 +1296,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private fireNotificationHook(notification: AgentTaskNotification): void { + if (!this.lifecycleActive()) return; void this.dispatcher.dispatch( new TaskNotified({ agentId: this.scopeContext.agentId, @@ -1290,6 +1312,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private isTerminalNotificationSuppressed(taskId: string): boolean { return ( + this.exitSuppressionArmed || this.tasks.get(taskId)?.terminalNotificationSuppressed === true || this.ghosts.get(taskId)?.terminalNotificationSuppressed === true ); diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts index d88df865283..c1cf3349d0e 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -22,6 +22,7 @@ export interface ToolCallStartedPayload { export interface ToolExecutorExecuteOptions { readonly signal: AbortSignal; + readonly steerSignal?: AbortSignal; readonly turnId: number; readonly trace?: LLMRequestTrace; readonly onToolCall?: (payload: ToolCallStartedPayload) => void; diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 8d23456a2f5..0432fe51ae6 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -527,6 +527,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { trace: options.trace, metadata, signal, + steerSignal: options.steerSignal, onUpdate: (update) => { if (signal.aborted) return; this.dispatchToolProgress(call, update, options); diff --git a/packages/agent-core-v2/src/agent/tools/fileReadSource.ts b/packages/agent-core-v2/src/agent/tools/fileReadSource.ts new file mode 100644 index 00000000000..f6b8026d976 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/fileReadSource.ts @@ -0,0 +1,64 @@ +import { readUtf8Lines } from '#/_base/execEnv/decodeText'; +import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { parseDaemonFileUrl } from '#/agent/media/mediaRef'; +import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import type { ExecutableToolResult } from '#/tool/toolContract'; + +export interface FileReadSource { + readonly name: string; + readonly localPath?: string; + stat(): Promise; + readBytes(n?: number): Promise; + readLines(): AsyncIterable; +} + +export function withAttachmentLocation(result: ExecutableToolResult, source: FileReadSource): ExecutableToolResult { + if (!result.isError || source.localPath === undefined || typeof result.output !== 'string') return result; + return { ...result, output: `${result.output}\nServer-local attachment path: ${JSON.stringify(source.localPath)}` }; +} + +export function runtimeFileSource(fs: IHostFileSystem, path: string): FileReadSource { + return { + name: path, + stat: () => fs.stat(path), + readBytes: (n) => fs.readBytes(path, n), + readLines: () => fs.readLines(path, { errors: 'strict' }), + }; +} + +export async function attachmentFileSource(reference: string, store?: ISessionMediaStore): Promise { + const ref = parseDaemonFileUrl(reference); + const open = async () => { + const file = ref === undefined ? undefined : await store?.open(ref.fileId); + if (file === undefined) throw new Error(`Attachment ${JSON.stringify(reference)} is not available in the current session.`); + return file; + }; + const initial = await open(); + return { + name: initial.name, + localPath: initial.path, + stat: async () => ({ isFile: true, isDirectory: false, size: (await open()).size }), + readBytes: async (n) => { + const file = await open(); + const size = Math.min(n ?? file.size, file.size); + if (size === 0) return new Uint8Array(); + const chunks: Buffer[] = []; + for await (const chunk of file.stream({ start: 0, end: size - 1 })) chunks.push(Buffer.from(chunk)); + const bytes = Buffer.concat(chunks); + if (bytes.length !== size) throw new Error('Attachment changed or became unavailable while reading.'); + return bytes; + }, + readLines: async function* () { + const file = await open(); + const checkedStream = async function* () { + let size = 0; + for await (const chunk of file.stream()) { + size += chunk.length; + yield chunk; + } + if (size !== file.size) throw new Error('Attachment changed or became unavailable while reading.'); + }; + yield* readUtf8Lines(checkedStream()); + }, + }; +} diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.md b/packages/agent-core-v2/src/agent/tools/os/read/read.md index d3ab6c58a06..177d26441ce 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.md +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.md @@ -1,5 +1,7 @@ Read a text file from the local filesystem. +The path may be a `kimi-file://` attachment reference. Its bytes come from the current session's storage, independently of the workspace runtime. Next Read keeps the reference so pagination also works after a fork. For a binary attachment, the error includes a server-local path when available; a converter must be able to access that filesystem. ReadMediaFile accepts the same reference for images and videos. + If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files matching a name pattern (Glob lists files only, never directories). Use `Grep` only when the task is to search for unknown content or locations. When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn. diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.ts b/packages/agent-core-v2/src/agent/tools/os/read/read.ts index 05262569a30..060f82571c8 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.ts @@ -15,7 +15,7 @@ export const ReadInputSchema = z.object({ path: z .string() .describe( - 'Path to a text file. Relative paths resolve against the working directory; a path outside the working directory must be absolute. Directories are not supported; use `ls` via Bash for a known directory, or Glob for pattern search.', + 'Path to a text file or a kimi-file:// attachment reference in the current session. Relative filesystem paths resolve against the working directory; a path outside the working directory must be absolute. Directories are not supported; use `ls` via Bash for a known directory, or Glob for pattern search.', ), line_offset: z .union([PositiveLineOffsetSchema, TailLineOffsetSchema]) diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 9c63700a94b..8f3b98b4282 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -1,5 +1,8 @@ import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime'; +import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { isDaemonFileUrl } from '#/agent/media/mediaRef'; +import { attachmentFileSource, runtimeFileSource, withAttachmentLocation, type FileReadSource } from '#/agent/tools/fileReadSource'; import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; @@ -18,7 +21,7 @@ import { } from '#/tool/path-access'; import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type'; import { toInputJsonSchema } from '#/tool/input-schema'; -import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; +import { literalRulePattern, matchesGlobRuleSubject, matchesPathRuleSubject } from '#/tool/rule-match'; import { makeCarriageReturnsVisible, splitLinesKeepingTerminator, type LineEndingStyle } from '#/_base/text/line-endings'; import { detectTextEncoding, type UtfTextEncoding } from '#/_base/text/encoding'; import { renderPrompt } from '#/_base/utils/render-prompt'; @@ -175,6 +178,7 @@ export class ReadTool implements IReadTool { @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService, @IConfigService private readonly config: IConfigService, + @ISessionMediaStore private readonly attachmentStore?: ISessionMediaStore, ) {} private limits(): { defaultMaxChars: number; maxChars: number } { @@ -190,10 +194,11 @@ export class ReadTool implements IReadTool { return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; } - resolveExecution(args: ReadInput): ToolExecution { + resolveExecution(args: ReadInput): ToolExecution | Promise { if (args.column_offset !== undefined && (args.line_offset ?? 1) < 0) { return { isError: true, output: 'column_offset is only supported for forward reads. Use a positive line_offset or the forward Next Read arguments.' }; } + if (isDaemonFileUrl(args.path)) return this.attachmentExecution(args); const inspected = inspectAgentRuntime(this.runtime); const view = new RuntimeWorkspaceView(inspected, { workDir: this.workspaceCtx.workDir, @@ -224,7 +229,7 @@ export class ReadTool implements IReadTool { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } const eventLog = this.resultTruncation.isWireJournalPath(path); - const result = await this.execution(lease.runtime.fs!, args, path, eventLog); + const result = await this.execution(runtimeFileSource(lease.runtime.fs!, path), args, eventLog); return { ...result, spillExempt: true }; } finally { lease.dispose(); @@ -233,16 +238,30 @@ export class ReadTool implements IReadTool { }; } + private async attachmentExecution(args: ReadInput): Promise { + const source = await attachmentFileSource(args.path, this.attachmentStore); + return { + accesses: ToolAccesses.readFile(source.localPath ?? args.path), + description: `Reading ${args.path}`, + display: { kind: 'file_io', operation: 'read', path: source.localPath ?? args.path }, + approvalRule: literalRulePattern(this.name, args.path), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.path), + execute: async () => ({ + ...withAttachmentLocation(await this.execution(source, args, false), source), + spillExempt: true, + }), + }; + } + private async execution( - fs: IHostFileSystem, + source: FileReadSource, args: ReadInput, - safePath: string, eventLog: boolean, ): Promise { try { let stat: Awaited>; try { - stat = await fs.stat(safePath); + stat = await source.stat(); } catch (error) { if (isFileNotFoundError(error)) { return { isError: true, output: `"${args.path}" does not exist.` }; @@ -253,8 +272,8 @@ export class ReadTool implements IReadTool { return { isError: true, output: `"${args.path}" is not a file.` }; } - const header = await fs.readBytes(safePath, MEDIA_SNIFF_BYTES); - const fileType = detectFileType(safePath, header); + const header = await source.readBytes(MEDIA_SNIFF_BYTES); + const fileType = detectFileType(source.name, header); if (fileType.kind === 'image' || fileType.kind === 'video') { return { isError: true, @@ -276,7 +295,7 @@ export class ReadTool implements IReadTool { 'Convert it to UTF-8 first (e.g. with `iconv`).', }; } - const bytes = await fs.readBytes(safePath); + const bytes = await source.readBytes(); let decoded: string; try { decoded = new TextDecoder(detection.encoding, { fatal: true }).decode(bytes); @@ -294,7 +313,7 @@ export class ReadTool implements IReadTool { output: notReadableFileOutput(args.path), }; } else { - readLines = () => fs.readLines(safePath, { errors: 'strict' }); + readLines = () => source.readLines(); } const limits = this.limits(); @@ -311,7 +330,7 @@ export class ReadTool implements IReadTool { const rereadsFile = detectedEncoding === undefined && (args.n_lines ?? Infinity) < -lineOffset; const result = await this.readTail(readLines, request); if (!result.isError && rereadsFile) { - const currentStat = await fs.stat(safePath); + const currentStat = await source.stat(); if (!currentStat.isFile || currentStat.size !== stat.size || currentStat.mtimeMs !== stat.mtimeMs || currentStat.ino !== stat.ino) { return { isError: true, output: 'File changed while reading its tail. Retry Read with the updated file.' }; @@ -579,5 +598,4 @@ export class ReadTool implements IReadTool { registerAgentToolService(IReadTool, ReadTool, { name: 'Read', domain: 'os/backends', - requiredRuntimeCapabilities: ['fs'], }); diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts index a3bdda43889..f40fc0716c2 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts @@ -17,7 +17,7 @@ export const ReadMediaFileInputSchema = z.object({ path: z .string() .describe( - 'Path to an image or video file. Relative paths resolve against the working directory; ' + + 'Path to an image or video file, or a kimi-file:// attachment reference in the current session. Relative filesystem paths resolve against the working directory; ' + 'a path outside the working directory must be absolute. ' + 'Directories and text files are not supported.', ), diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media.md b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media.md index 2e577989c48..5a3b43552e9 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media.md +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media.md @@ -1,5 +1,7 @@ Read media content from a file. +The path may be a `kimi-file://` attachment reference. Its bytes come from the current session's storage, independently of the workspace runtime, including after a fork. Any reported local attachment path belongs to the server; external converters must be able to access that filesystem. + **Tips:** - Make sure you follow the description of each tool parameter. - A `` tag accompanies the media content; it summarizes the mime type, byte size and, for images, the original pixel dimensions, and states how the image was delivered (untouched, downsampled, cropped, or native resolution). When outputting coordinates, give relative coordinates first and compute absolute coordinates from the original image size. After generating or editing media via commands or scripts, read the result back before continuing. diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts index e5308966f04..fff7ef5bae4 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts @@ -3,8 +3,10 @@ import type { ContentPart } from '#human/llm/message'; import { VideoUploadUnsupportedError } from '#/llm-adapter/contract/errors'; import { inlineVideoPart, isVideoUploadAuthError } from '#/agent/media/videoUpload'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { isDaemonFileUrl } from '#/agent/media/mediaRef'; +import { attachmentFileSource, runtimeFileSource, withAttachmentLocation, type FileReadSource } from '#/agent/tools/fileReadSource'; -import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; import type { HostEnvironmentInfo } from '#/os/interface/hostEnvironment'; import { inspectAgentRuntime, type IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; @@ -37,7 +39,7 @@ import { } from '#/agent/media/image-format-policy'; import { providerImagePolicy } from '#human/llm/media/image-formats'; import { toInputJsonSchema } from '#/tool/input-schema'; -import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; +import { literalRulePattern, matchesGlobRuleSubject, matchesPathRuleSubject } from '#/tool/rule-match'; import { renderPrompt } from '#/_base/utils/render-prompt'; import { MAX_MEDIA_BYTES, @@ -194,6 +196,7 @@ export class ReadMediaFileTool implements AgentTool { telemetry?: ITelemetryService, inlineVideoSupported?: boolean, providerType?: string, + private readonly attachmentStore?: ISessionMediaStore, ) { this.description = buildDescription(capabilities); this.telemetry = telemetry; @@ -221,10 +224,13 @@ export class ReadMediaFileTool implements AgentTool { return inlineVideoPart(data, mimeType); } - resolveExecution(args: ReadMediaFileInput): ToolExecution { + resolveExecution(args: ReadMediaFileInput): ToolExecution | Promise { if (!args.path) { return { isError: true, output: 'File path cannot be empty.' }; } + if (isDaemonFileUrl(args.path)) { + return this.attachmentExecution(args); + } const inspected = inspectAgentRuntime(this.runtime); const env = inspected.environment; const view = new RuntimeWorkspaceView(inspected, { @@ -254,7 +260,7 @@ export class ReadMediaFileTool implements AgentTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } - return await this.execution(args, path, lease.runtime.fs!, env); + return await this.execution(args, runtimeFileSource(lease.runtime.fs!, path), env); } finally { lease.dispose(); } @@ -262,18 +268,32 @@ export class ReadMediaFileTool implements AgentTool { }; } + private async attachmentExecution(args: ReadMediaFileInput): Promise { + const source = await attachmentFileSource(args.path, this.attachmentStore); + return { + accesses: ToolAccesses.readFile(source.localPath ?? args.path), + description: `Reading media: ${args.path}`, + display: { kind: 'file_io', operation: 'read', path: source.localPath ?? args.path }, + approvalRule: literalRulePattern(this.name, args.path), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.path), + execute: async () => withAttachmentLocation( + await this.execution(args, source, { osKind: 'unknown' }), source, + ), + }; + } + private async execution( args: ReadMediaFileInput, - safePath: string, - fs: IHostFileSystem, - env: HostEnvironmentInfo, + source: FileReadSource, + env: Pick, ): Promise { if (!args.path) { return { isError: true, output: 'File path cannot be empty.' }; } try { - const header = await fs.readBytes(safePath, MEDIA_SNIFF_BYTES); + const safePath = source.name; + const header = await source.readBytes(MEDIA_SNIFF_BYTES); const fileType = detectFileType(safePath, header, 'media'); if (fileType.kind === 'text') { @@ -305,7 +325,7 @@ export class ReadMediaFileTool implements AgentTool { ) { return { isError: true, - output: buildImageConversionGuidance(args.path, fileType.mimeType, env.osKind), + output: buildImageConversionGuidance(source.localPath ?? args.path, fileType.mimeType, env.osKind), }; } if (fileType.kind === 'video' && !this.capabilities.video_in) { @@ -317,7 +337,7 @@ export class ReadMediaFileTool implements AgentTool { }; } - const stat = await fs.stat(safePath); + const stat = await source.stat(); if (stat.size === 0) { return { isError: true, output: `"${args.path}" is empty.` }; } @@ -380,7 +400,7 @@ export class ReadMediaFileTool implements AgentTool { }; } - const data = Buffer.from(await fs.readBytes(safePath)); + const data = Buffer.from(await source.readBytes()); let dimensions = fileType.kind === 'image' ? sniffImageDimensions(data) : null; let mediaPart: ContentPart; let delivery: ImageDelivery | undefined; @@ -447,7 +467,7 @@ export class ReadMediaFileTool implements AgentTool { return { isError: true, output: buildOversizedImageConversionGuidance( - args.path, + source.localPath ?? args.path, fileType.mimeType, env.osKind, compressed.finalByteLength, @@ -489,7 +509,8 @@ export class ReadMediaFileTool implements AgentTool { } const tag = fileType.kind === 'image' ? 'image' : 'video'; - const openText = `<${tag} path="${safePath}">`; + const tagPath = isDaemonFileUrl(args.path) ? args.path : safePath; + const openText = `<${tag} path="${tagPath}">`; const closeText = ``; const note = buildMediaNote({ diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index 498054b24f0..9856173d31d 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -15,7 +15,7 @@ import { formatPlainObject } from '#/agent/task/tools/format'; import { formatTaskList } from '#/agent/tools/task/task-list/taskListTool'; import { IFlagService } from '#/app/flag/flag'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { abortError, linkAbortSignal } from '#/_base/utils/abort'; +import { abortError, isAbortError, linkAbortSignal } from '#/_base/utils/abort'; import { WAIT_FOR_FLAG_ID } from './flag'; import { IWaitForTool, WaitForInputSchema, type WaitForInput } from './task-wait'; import WAIT_FOR_DESCRIPTION from './task-wait.md?raw'; @@ -26,7 +26,7 @@ const PAGING_HINT_LINES = 300; const PROGRESS_INTERVAL_MS = 1_000; -type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted'; +type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted' | 'interrupted'; function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined { if (info.status === 'timed_out') return 'timed_out'; @@ -166,13 +166,23 @@ export class WaitForTool implements IWaitForTool { } let waited: AgentTaskInfo | undefined; + const signal = ctx.steerSignal === undefined + ? ctx.signal + : AbortSignal.any([ctx.signal, ctx.steerSignal]); const progress = startWaitProgress(args, this.tasks, ctx.onUpdate, startedAt); try { waited = args.task_id === undefined - ? await this.waitAny(runningAtStart, timeoutMs, ctx.signal) - : await this.tasks.wait(args.task_id, timeoutMs, ctx.signal); + ? await this.waitAny(runningAtStart, timeoutMs, signal) + : await this.tasks.wait(args.task_id, timeoutMs, signal); } catch (error) { + if ( + !ctx.signal.aborted && ctx.steerSignal?.aborted && + (error === ctx.steerSignal.reason || isAbortError(error)) + ) { + this.track(args, startedAt, timeoutMs, 'interrupted', 0); + return { output: this.formatInterrupted(args, startedAt, timeoutMs), isError: false }; + } this.track(args, startedAt, timeoutMs, 'aborted', 0); throw error; } finally { @@ -254,6 +264,24 @@ export class WaitForTool implements IWaitForTool { return lines.join('\n'); } + private formatInterrupted(args: WaitForInput, startedAt: number, timeoutMs: number): string { + const lines = [ + formatPlainObject({ + waitStatus: 'interrupted', + reason: 'steer', + taskId: args.task_id, + waitedMs: Date.now() - startedAt, + timeoutMs, + }), + 'New input ended this wait early. Read the new input before deciding what to do next. Background tasks have not been stopped; completion still arrives via automatic notification.', + ]; + const running = this.tasks.list(true); + if (running.length > 0) { + lines.push('', '[still_running]', formatTaskList(running, true)); + } + return lines.join('\n'); + } + private async formatCompleted( finished: AgentTaskInfo, extras: readonly AgentTaskInfo[], diff --git a/packages/agent-core-v2/src/agent/usage/usageEvents.ts b/packages/agent-core-v2/src/agent/usage/usageEvents.ts index 35e5567266e..441c1b609dc 100644 --- a/packages/agent-core-v2/src/agent/usage/usageEvents.ts +++ b/packages/agent-core-v2/src/agent/usage/usageEvents.ts @@ -32,16 +32,6 @@ export type AgentPhase = readonly stepId: string; readonly since: number; } - | { - readonly kind: 'streaming'; - readonly turnId: number; - readonly step: number; - readonly stepId: string; - readonly stream: 'assistant' | 'thinking' | 'tool_call'; - readonly toolCallId?: string; - readonly toolName?: string; - readonly since: number; - } | { readonly kind: 'tool_call'; readonly turnId: number; diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index 9003c1d36ea..fc0db298859 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -24,6 +24,8 @@ export interface ConfigKeyDeprecation { readonly message?: string; } +export type ConfigCollectDiagnostics = (rawSection: unknown) => readonly ConfigDiagnostic[]; + export type EnvBindings = EnvBinding | { [K in keyof T]?: EnvBinding | EnvBindings }; export type AnyEnvBindings = EnvBinding | { readonly [key: string]: EnvBinding | AnyEnvBindings }; @@ -93,6 +95,7 @@ export interface ConfigSection { readonly fromToml?: ConfigFromToml; readonly toToml?: ConfigToToml; readonly deprecations?: readonly ConfigKeyDeprecation[]; + readonly collectDiagnostics?: ConfigCollectDiagnostics; } export interface RegisterSectionOptions { @@ -104,6 +107,7 @@ export interface RegisterSectionOptions { readonly fromToml?: ConfigFromToml; readonly toToml?: ConfigToToml; readonly deprecations?: readonly ConfigKeyDeprecation[]; + readonly collectDiagnostics?: ConfigCollectDiagnostics; } export interface ConfigEffectiveOverlay { diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index fdc2dc6f5aa..fadb6a96ff5 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -151,7 +151,8 @@ function isSameSection( existing.fromToml === options.fromToml && existing.toToml === options.toToml && deepEqual(existing.defaultValue, options.defaultValue) && - deepEqual(existing.deprecations, options.deprecations) + deepEqual(existing.deprecations, options.deprecations) && + existing.collectDiagnostics === options.collectDiagnostics ); } @@ -249,6 +250,7 @@ export class ConfigRegistry extends Disposable implements IConfigRegistry { fromToml: options.fromToml, toToml: options.toToml, deprecations: options.deprecations, + collectDiagnostics: options.collectDiagnostics, }); this._onDidRegisterSection.fire({ domain }); } @@ -576,6 +578,13 @@ export class ConfigService extends Disposable implements IConfigService { for (const diagnostic of collectKeyDeprecations(nextRawSnake, this.registry.listSections())) { this.pushDiagnostic(diagnostic); } + for (const section of this.registry.listSections()) { + if (section.collectDiagnostics === undefined) continue; + const rawSection = nextRawSnake[camelToSnake(section.domain)]; + for (const diagnostic of section.collectDiagnostics(rawSection)) { + this.pushDiagnostic(diagnostic); + } + } if (source !== 'load' && JSON.stringify(nextRawSnake) === JSON.stringify(this.rawSnake)) { const scratch = { ...this.validated }; this.applySectionEnvBindings(scratch, true); diff --git a/packages/agent-core-v2/src/app/event/eventBus.ts b/packages/agent-core-v2/src/app/event/eventBus.ts index 99fdadd12f9..24412309930 100644 --- a/packages/agent-core-v2/src/app/event/eventBus.ts +++ b/packages/agent-core-v2/src/app/event/eventBus.ts @@ -18,6 +18,7 @@ export const IEventBus: ServiceIdentifier = createDecorator): AgentContext | undefined; onAgent

>( agent: AgentContext, diff --git a/packages/agent-core-v2/src/app/event/eventBusService.ts b/packages/agent-core-v2/src/app/event/eventBusService.ts index 340bdb16f30..0dfac853eed 100644 --- a/packages/agent-core-v2/src/app/event/eventBusService.ts +++ b/packages/agent-core-v2/src/app/event/eventBusService.ts @@ -25,6 +25,10 @@ export class EventBusService extends Service implements ISessionEventBus { if (this.agents.get(agent.agentId) === agent) this.agents.delete(agent.agentId); } + isAgentActive(agent: AgentContext): boolean { + return this.agents.get(agent.agentId) === agent; + } + publish(event: Event2, agent?: AgentContext): void { const cls = event.constructor as Event2Class; if (cls.agentDomain) { diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 7b3e28dc6f8..99dd6413a3e 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -57,7 +57,9 @@ export class RestGateway implements IRestGateway { }, }); const turn = await handle.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; + if (turn === undefined) return undefined; + await turn.ready.catch(() => undefined); + return turn.id === undefined ? undefined : { turn_id: turn.id }; } async steer( sessionId: string, @@ -73,7 +75,9 @@ export class RestGateway implements IRestGateway { } }); const [steered] = await service.steer([queued.id]); const turn = await steered?.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; + if (turn === undefined) return undefined; + await turn.ready.catch(() => undefined); + return turn.id === undefined ? undefined : { turn_id: turn.id }; } cancel(sessionId: string, agentId: string, reason?: string): Promise { this.agent(sessionId, agentId).accessor.get(IAgentLoopService).cancel(undefined, reason); diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index db81f933ad1..e1b96b5bd7c 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { + type ConfigDiagnostic, type ConfigStripEnv, envBindings, stripEnvBoundFields, @@ -200,6 +201,54 @@ type _AssertModelsSection = AssertExact< Equal, ModelsSection> >; +const MODEL_OBJECT_FIELDS = new Set( + Object.entries(ModelRecordSchema.shape) + .filter(([, field]) => unwrapWrapperSchema(field as z.ZodTypeAny) instanceof z.ZodObject) + .map(([key]) => camelToSnake(key)), +); + +function unwrapWrapperSchema(schema: z.ZodTypeAny): z.ZodTypeAny { + let current = schema; + while ( + current instanceof z.ZodOptional || + current instanceof z.ZodNullable || + current instanceof z.ZodDefault + ) { + current = current.unwrap() as z.ZodTypeAny; + } + return current; +} + +function collectMalformedModelEntries(rawModels: unknown): ConfigDiagnostic[] { + if (!isPlainObject(rawModels)) return []; + const diagnostics: ConfigDiagnostic[] = []; + for (const [alias, entry] of Object.entries(rawModels)) { + if (!isPlainObject(entry)) continue; + if (entry['model'] !== undefined || entry['name'] !== undefined) continue; + diagnostics.push({ + domain: MODELS_SECTION, + severity: 'warning', + message: malformedModelMessage(alias, entry), + }); + } + return diagnostics; +} + +function malformedModelMessage(alias: string, entry: Record): string { + const base = `[models] entry '${alias}' is missing the 'model' field and cannot be used as a model`; + const dottedAlias = dottedAliasSuffix(alias, entry); + if (dottedAlias === undefined) return `${base}.`; + return `${base}; if the alias contains dots, quote the table name (e.g. [models."${dottedAlias}"]).`; +} + +function dottedAliasSuffix(alias: string, entry: Record): string | undefined { + for (const [key, value] of Object.entries(entry)) { + if (MODEL_OBJECT_FIELDS.has(key) || !isPlainObject(value)) continue; + return dottedAliasSuffix(`${alias}.${key}`, value) ?? `${alias}.${key}`; + } + return undefined; +} + export const modelsFromToml = (rawSnake: unknown): unknown => { if (!isPlainObject(rawSnake)) return rawSnake; const out: Record = {}; @@ -260,6 +309,7 @@ registerConfigSection(MODELS_SECTION, ModelsSectionSchema, { defaultValue: {}, fromToml: modelsFromToml, toToml: modelsToToml, + collectDiagnostics: collectMalformedModelEntries, }); export const THINKING_SECTION = 'thinking'; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexDirtyJournal.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexDirtyJournal.ts new file mode 100644 index 00000000000..2c5c46c50a6 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexDirtyJournal.ts @@ -0,0 +1,47 @@ +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +export const SESSION_INDEX_DIRTY_DIR = '.index-dirty'; + +const EMPTY = new Uint8Array(0); + +function dirtyScope(sessionsScope: string): string { + return `${sessionsScope}/${SESSION_INDEX_DIRTY_DIR}`; +} + +export async function markSessionDirty( + storage: IFileSystemStorageService, + sessionsScope: string, + sessionId: string, +): Promise { + await storage.append(dirtyScope(sessionsScope), `${sessionId}.${Date.now()}`, EMPTY, { + durable: false, + }); +} + +export async function listDirtyMarks( + storage: IFileSystemStorageService, + sessionsScope: string, +): Promise { + return storage.list(dirtyScope(sessionsScope)); +} + +export function dirtyMarkSessionIds(marks: readonly string[]): Set { + const ids = new Set(); + for (const name of marks) { + const dot = name.lastIndexOf('.'); + if (dot > 0) ids.add(name.slice(0, dot)); + } + return ids; +} + +export async function clearDirtyMarks( + storage: IFileSystemStorageService, + sessionsScope: string, + marks: readonly string[], +): Promise { + await Promise.all( + marks.map((name) => + storage.delete(dirtyScope(sessionsScope), name).catch(() => undefined), + ), + ); +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts index ca4e0065cea..61ac995c148 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts @@ -3,12 +3,15 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IntervalTimer } from '#/_base/utils/timer'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { databaseBaseEnabled } from '#/persistence/configSection'; import { IQueryStore } from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ISessionIndexMirror, type SessionSummary } from './sessionIndex'; +import { markSessionDirty } from './sessionIndexDirtyJournal'; import { SESSION_INDEX_MANIFEST, recencyColumn, @@ -33,24 +36,31 @@ export class SessionIndexMirror extends Disposable implements ISessionIndexMirro declare readonly _serviceBrand: undefined; private readonly pendingMap = new Map(); + private readonly pendingMarks = new Set>(); private readonly timer = this._register(new IntervalTimer({ unref: true })); private flushing: Promise | undefined; private consecutiveFailures = 0; private giveUpTracked = false; private disposed = false; private overflowLogged = false; + private readonly sessionsScope: string; constructor( @IQueryStore private readonly queryStore: IQueryStore, @IConfigService private readonly config: IConfigService, @ITelemetryService private readonly telemetry: ITelemetryService, @ILogService private readonly log: ILogService, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @IBootstrapService bootstrap: IBootstrapService, ) { super(); + this.sessionsScope = bootstrap.scope('sessions'); this._register( toDisposable(() => { this.disposed = true; - const pending = this.drain().catch(() => {}); + const pending = Promise.all(this.pendingMarks) + .catch(() => {}) + .then(() => this.drain().catch(() => {})); pendingDrains.add(pending); void pending.finally(() => pendingDrains.delete(pending)); }), @@ -58,7 +68,13 @@ export class SessionIndexMirror extends Disposable implements ISessionIndexMirro } record(summary: SessionSummary): void { - if (this.disposed || !databaseBaseEnabled(this.config)) return; + if (this.disposed) return; + const mark = markSessionDirty(this.storage, this.sessionsScope, summary.id).catch((error) => { + this.log.debug('session index dirty mark failed', { error: String(error) }); + }); + this.pendingMarks.add(mark); + void mark.finally(() => this.pendingMarks.delete(mark)); + if (!databaseBaseEnabled(this.config)) return; if (this.pendingMap.size >= MAX_PENDING && !this.pendingMap.has(summary.id)) { if (!this.overflowLogged) { this.overflowLogged = true; @@ -168,18 +184,26 @@ export class SessionIndexMirror extends Disposable implements ISessionIndexMirro for (const [id, summary] of chunk) { if (this.pendingMap.get(id) === summary) this.pendingMap.delete(id); } + if (this.consecutiveFailures > 0) { + this.log.info('session index mirror flush recovered', { + afterFailures: this.consecutiveFailures, + pending: this.pendingMap.size, + }); + } this.consecutiveFailures = 0; this.giveUpTracked = false; } catch (error) { this.consecutiveFailures += 1; - this.log.warn('failed to flush session index mirror chunk', { - pending: this.pendingMap.size, - failures: this.consecutiveFailures, - error: String(error), - }); + if (this.consecutiveFailures === 1) { + this.log.warn('failed to flush session index mirror chunk', { + pending: this.pendingMap.size, + error: String(error), + }); + } if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { this.log.warn('session index mirror giving up until the next record; reconciliation will heal', { pending: this.pendingMap.size, + failures: this.consecutiveFailures, }); if (!this.giveUpTracked) { this.giveUpTracked = true; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts index 6d0509927b9..e088eddbfc8 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts @@ -2,6 +2,8 @@ import type { SessionSummary } from './sessionIndex'; export const SESSION_INDEX_MANIFEST = 'sessionIndex'; +export const SESSION_INDEX_SCHEMA_VERSION = 2; + export const PARENT_INDEX_NAME = 'byParent'; export interface SessionWorkspaceCounts { diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts index ec2130319ac..bb6096e85b8 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts @@ -1,13 +1,18 @@ import { ILogService } from '#/_base/log/log'; -import { SESSION_INDEX_KEY, SESSION_INDEX_SCOPE } from '#/app/workspace/workspaceAlias'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IQueryStore, type WriteOp } from '#/persistence/interface/queryStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { PARENT_SESSION_ID_KEY, type SessionSummary } from './sessionIndex'; +import { + clearDirtyMarks, + dirtyMarkSessionIds, + listDirtyMarks, +} from './sessionIndexDirtyJournal'; import { PARENT_INDEX_NAME, SESSION_INDEX_MANIFEST, + SESSION_INDEX_SCHEMA_VERSION, recencyColumn, sessionCollection, sessionCountersCollection, @@ -19,7 +24,6 @@ import { listWorkspaceIds, mapBounded, readSessionSummary, - sessionStateMaxMtime, summaryEquals, } from './sessionIndexSource'; @@ -49,7 +53,7 @@ export interface ReconcileResult { export interface AuthoritativeScan { readonly summaries: SessionSummary[]; readonly counts: Map; - readonly sourceMaxMtimeMs: number; + readonly sourceSessionCount: number; } interface ScanSlot { @@ -105,6 +109,7 @@ export class SessionIndexProjector { scan: Promise, ): Promise { const { queryStore, log } = this.deps; + const epoch = queryStore.storeEpoch(); const collection = sessionCollection(generation); const counters = sessionCountersCollection(generation); await queryStore.dropCollection(collection); @@ -115,7 +120,7 @@ export class SessionIndexProjector { field: `custom.${PARENT_SESSION_ID_KEY}`, }); - const { summaries, counts, sourceMaxMtimeMs } = await scan; + const { summaries, counts, sourceSessionCount } = await scan; await this.batchChunks( summaries.map((summary) => ({ kind: 'put' as const, @@ -126,10 +131,15 @@ export class SessionIndexProjector { })), ); await this.writeCounters(counters, counts); - await queryStore.setCheckpoint(SESSION_INDEX_MANIFEST, { - seq: generation, - sourceMaxMtimeMs, - }); + await queryStore.setCheckpoint( + SESSION_INDEX_MANIFEST, + { + seq: generation, + sourceSessionCount, + schemaVersion: SESSION_INDEX_SCHEMA_VERSION, + }, + epoch, + ); log.info('session index generation published', { generation, sessions: summaries.length, @@ -152,45 +162,118 @@ export class SessionIndexProjector { } async reconcile(generation: number): Promise { - const { queryStore, log } = this.deps; + const { queryStore, docs, storage, log, sessionsScope } = this.deps; + const epoch = queryStore.storeEpoch(); const collection = sessionCollection(generation); const counters = sessionCountersCollection(generation); - const { summaries, counts, sourceMaxMtimeMs } = await this.scanAuthoritative(); - const authoritativeIds = new Set(summaries.map((s) => s.id)); + const marks = await listDirtyMarks(storage, sessionsScope); + const changed = dirtyMarkSessionIds(marks); + const workspaceIds = await listWorkspaceIds(storage, sessionsScope); + const authoritative = new Map(); + for (const workspaceId of workspaceIds) { + for (const sessionId of await listSessionIds(storage, sessionsScope, workspaceId)) { + authoritative.set(sessionId, workspaceId); + } + } const storedKeys = await queryStore.listKeys(collection); - const stored = await queryStore.getMany( - collection, - summaries.map((s) => s.id), - ); + const stored = new Set(storedKeys); + for (const sessionId of authoritative.keys()) { + if (!stored.has(sessionId)) changed.add(sessionId); + } + for (const key of storedKeys) { + if (!authoritative.has(key)) changed.add(key); + } + const olds = await queryStore.getMany(collection, [...changed]); const upserts: WriteOp[] = []; - for (const summary of summaries) { - const existing = stored.get(summary.id); + const removals: WriteOp[] = []; + const applied = new Map(); + const removed = new Set(); + await mapBounded([...changed], SCAN_CONCURRENCY, async (sessionId) => { + const workspaceId = authoritative.get(sessionId); + const summary = + workspaceId === undefined + ? undefined + : await readSessionSummary(docs, sessionsScope, workspaceId, sessionId); + if (summary === undefined) { + if (olds.get(sessionId) !== undefined) { + removals.push({ kind: 'delete', collection, key: sessionId }); + removed.add(sessionId); + } + return; + } + applied.set(sessionId, summary); + const existing = olds.get(sessionId); if (existing === undefined || !summaryEquals(existing, summary)) { upserts.push({ kind: 'put', collection, - key: summary.id, + key: sessionId, value: withRecencyField(generation, summary), columns: { [recencyColumn(generation)]: summary.updatedAt }, }); } + }); + + const deltas = new Map(); + const bump = (workspaceId: string, field: 'active' | 'archived', by: number): void => { + const entry = deltas.get(workspaceId) ?? { active: 0, archived: 0 }; + entry[field] += by; + deltas.set(workspaceId, entry); + }; + for (const summary of applied.values()) { + const old = olds.get(summary.id); + if (old === undefined) { + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.workspaceId !== summary.workspaceId) { + bump(old.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.archived !== summary.archived) { + bump(summary.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } + } + for (const sessionId of removed) { + const old = olds.get(sessionId); + if (old !== undefined) bump(old.workspaceId, old.archived ? 'archived' : 'active', -1); + } + const current = await queryStore.getMany(counters, [...deltas.keys()]); + const counterOps: WriteOp[] = [...deltas.entries()].map(([workspaceId, delta]) => { + const base = current.get(workspaceId) ?? { active: 0, archived: 0 }; + const value: SessionWorkspaceCounts = { + active: Math.max(0, base.active + delta.active), + archived: Math.max(0, base.archived + delta.archived), + }; + return { kind: 'put', collection: counters, key: workspaceId, value }; + }); + const totals = new Map(); + for (const workspaceId of authoritative.values()) { + totals.set(workspaceId, (totals.get(workspaceId) ?? 0) + 1); + } + for (const key of await queryStore.listKeys(counters)) { + if (!totals.has(key)) counterOps.push({ kind: 'delete', collection: counters, key }); } - const removals: WriteOp[] = storedKeys - .filter((key) => !authoritativeIds.has(key)) - .map((key) => ({ kind: 'delete' as const, collection, key })); - await this.batchChunks([...upserts, ...removals]); - await this.writeCounters(counters, counts); + await this.batchChunks([...upserts, ...removals, ...counterOps]); const manifest = await queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); if (manifest?.seq === generation) { - await queryStore.setCheckpoint(SESSION_INDEX_MANIFEST, { - seq: generation, - sourceMaxMtimeMs: Math.max(manifest.sourceMaxMtimeMs ?? 0, sourceMaxMtimeMs), - }); + await queryStore.setCheckpoint( + SESSION_INDEX_MANIFEST, + { + seq: generation, + sourceSessionCount: authoritative.size, + schemaVersion: SESSION_INDEX_SCHEMA_VERSION, + }, + epoch, + ); } - const result = { sessions: summaries.length, upserted: upserts.length, removed: removals.length }; + await clearDirtyMarks(storage, sessionsScope, marks); + const result = { + sessions: authoritative.size, + upserted: upserts.length, + removed: removals.length, + }; if (result.upserted > 0 || result.removed > 0) { log.info('session index reconciliation repaired drift', { generation, ...result }); } @@ -198,17 +281,16 @@ export class SessionIndexProjector { } private async scanAuthoritative(): Promise { - const { storage, docs, sessionsScope, log } = this.deps; + const { storage, docs, sessionsScope } = this.deps; const summaries: SessionSummary[] = []; const counts = new Map(); - let sourceMaxMtimeMs = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0; + let sourceSessionCount = 0; for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) { const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId); - const found = await mapBounded(sessionIds, SCAN_CONCURRENCY, async (sessionId) => { - const mtime = await sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log); - if (mtime > sourceMaxMtimeMs) sourceMaxMtimeMs = mtime; - return readSessionSummary(docs, sessionsScope, workspaceId, sessionId); - }); + sourceSessionCount += sessionIds.length; + const found = await mapBounded(sessionIds, SCAN_CONCURRENCY, async (sessionId) => + readSessionSummary(docs, sessionsScope, workspaceId, sessionId), + ); const entry = counts.get(workspaceId) ?? { active: 0, archived: 0 }; for (const summary of found) { summaries.push(summary); @@ -217,7 +299,7 @@ export class SessionIndexProjector { } counts.set(workspaceId, entry); } - return { summaries, counts, sourceMaxMtimeMs }; + return { summaries, counts, sourceSessionCount }; } private async writeCounters( diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index eb55317feec..45b2b330d48 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -31,9 +31,11 @@ import { type SessionListQuery, type SessionSummary, } from './sessionIndex'; +import { markSessionDirty } from './sessionIndexDirtyJournal'; import { PARENT_INDEX_NAME, SESSION_INDEX_MANIFEST, + SESSION_INDEX_SCHEMA_VERSION, recencyColumn, sessionCollection, sessionCountersCollection, @@ -45,7 +47,7 @@ import { listSessionIds, listWorkspaceIds, readSessionSummary, - scanSessionsMaxMtime, + scanSessionsFreshness, summaryMatchesChildOf, } from './sessionIndexSource'; @@ -79,6 +81,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private statusReason: string | undefined; private degradedCount = 0; private nextPrepareRetryAt = 0; + private lastDegradedKey: string | undefined; private prepareFlight: Promise | undefined; private projectFlight: Promise | undefined; private readonly reconcileTimer = this._register(new IntervalTimer({ unref: true })); @@ -132,7 +135,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { this.state = 'preparing'; try { const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); - if (manifest === undefined || !(await this.manifestFresh(manifest))) { + if (manifest === undefined || manifest.schemaVersion !== SESSION_INDEX_SCHEMA_VERSION) { const projection = this.ensureProjection(); if (deadlineMs === undefined) { await projection; @@ -147,6 +150,29 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } else { this.generation = manifest.seq; await this.ensureSchema(manifest.seq); + if (!(await this.manifestFresh(manifest))) { + try { + const reconciliation = this.projector.reconcile(manifest.seq); + if (deadlineMs === undefined) { + await reconciliation; + } else { + await Promise.race([ + reconciliation, + new Promise((resolve) => { + setTimeout(resolve, deadlineMs); + }), + ]); + } + } catch (error) { + const published = await this.queryStore + .getCheckpoint(SESSION_INDEX_MANIFEST) + .catch(() => undefined); + if (published === undefined) throw error; + this.log.warn('session index startup reconciliation failed; serving the published generation', { + error: String(error), + }); + } + } } const published = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); if (published !== undefined) { @@ -160,12 +186,12 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } private async manifestFresh(manifest: Checkpoint): Promise { - const published = manifest.sourceMaxMtimeMs; - if (published === undefined) return false; + if (manifest.sourceSessionCount === undefined) return false; try { - return (await scanSessionsMaxMtime(this.storage, this.sessionsScope, this.log)) <= published; + const scan = await scanSessionsFreshness(this.storage, this.sessionsScope); + return scan.dirtyMarkCount === 0 && scan.sessionCount === manifest.sourceSessionCount; } catch (error) { - this.log.warn('session index freshness check failed; re-projecting', { + this.log.warn('session index freshness check failed; treating the index as stale', { error: String(error), }); return false; @@ -241,6 +267,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return; } this.generation = manifest.seq; + if (await this.manifestFresh(manifest)) return; await this.projector.reconcile(manifest.seq); } catch (error) { this.log.warn('session index reconciliation failed', { error: String(error) }); @@ -248,8 +275,12 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } private markReady(): void { + if (this.state === 'degraded') { + this.log.info('session index read model recovered', { degradedCount: this.degradedCount }); + } this.state = 'ready'; this.statusReason = undefined; + this.lastDegradedKey = undefined; this.ensureReconcileTimer(); } @@ -261,6 +292,9 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { this.ensureReconcileTimer(); const detail = error instanceof Error ? error.message : typeof error === 'string' ? error : undefined; + const episodeKey = `${reason}:${detail ?? ''}`; + if (episodeKey === this.lastDegradedKey) return; + this.lastDegradedKey = episodeKey; this.log.warn('session index read model degraded; serving authoritative reads', { reason, ...(detail !== undefined ? { error: detail } : {}), @@ -317,6 +351,11 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { }, () => Promise.resolve(), ); + try { + await markSessionDirty(this.storage, this.sessionsScope, id); + } catch (error) { + this.log.warn('session index dirty mark failed', { error: String(error) }); + } } private async withReadModel( diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts index 8ee0e68be17..adde8cd41a1 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -1,17 +1,11 @@ -import { ILogService } from '#/_base/log/log'; -import { SESSION_INDEX_KEY, SESSION_INDEX_SCOPE } from '#/app/workspace/workspaceAlias'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { - IFileSystemStorageService, - StorageError, - StorageErrors, -} from '#/persistence/interface/storage'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, type SessionSummary } from './sessionIndex'; +import { SESSION_INDEX_DIRTY_DIR, listDirtyMarks } from './sessionIndexDirtyJournal'; const META_SCOPE = 'session-meta'; const META_KEY = 'state.json'; -const MTIME_SCAN_CONCURRENCY = 16; export function parseTime(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; @@ -100,7 +94,7 @@ export async function listWorkspaceIds( sessionsScope: string, ): Promise { try { - return await storage.list(sessionsScope); + return (await storage.list(sessionsScope)).filter((entry) => entry !== SESSION_INDEX_DIRTY_DIR); } catch { return []; } @@ -176,53 +170,22 @@ export async function mapBounded( return out; } -async function stateFileMtime( - storage: IFileSystemStorageService, - scope: string, - log: ILogService | undefined, -): Promise { - try { - return await storage.mtime(scope, META_KEY); - } catch (error) { - if ( - error instanceof StorageError && - error.code === StorageErrors.codes.STORAGE_IO_FAILED && - error.details?.['errno'] === 'ENOTDIR' - ) { - log?.warn('session index skips a non-directory entry', { path: error.details['path'] }); - return undefined; - } - throw error; - } +export interface SessionsFreshness { + readonly dirtyMarkCount: number; + readonly sessionCount: number; } -export async function sessionStateMaxMtime( +export async function scanSessionsFreshness( storage: IFileSystemStorageService, sessionsScope: string, - workspaceId: string, - sessionId: string, - log?: ILogService, -): Promise { - const base = `${sessionsScope}/${workspaceId}/${sessionId}`; - const direct = await stateFileMtime(storage, base, log); - const nested = await stateFileMtime(storage, `${base}/${META_SCOPE}`, log); - return Math.max(direct ?? 0, nested ?? 0); -} - -export async function scanSessionsMaxMtime( - storage: IFileSystemStorageService, - sessionsScope: string, - log?: ILogService, -): Promise { - let max = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0; - for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) { - const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId); - const mtimes = await mapBounded(sessionIds, MTIME_SCAN_CONCURRENCY, (sessionId) => - sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log), - ); - for (const mtime of mtimes) { - if (mtime > max) max = mtime; - } +): Promise { + const [marks, workspaceIds] = await Promise.all([ + listDirtyMarks(storage, sessionsScope), + listWorkspaceIds(storage, sessionsScope), + ]); + let sessionCount = 0; + for (const workspaceId of workspaceIds) { + sessionCount += (await listSessionIds(storage, sessionsScope, workspaceId)).length; } - return max; + return { dirtyMarkCount: marks.length, sessionCount }; } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index d643d1a260c..489b429021a 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -29,7 +29,9 @@ import { IModelService } from '#/llm-adapter/model/model'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { IAgentActivityView } from '#/agent/activityView/activityView'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentTaskService } from '#/agent/task/task'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { ISessionLegacyService } from './sessionLegacy'; @@ -107,8 +109,10 @@ export class SessionLegacyService implements ISessionLegacyService { for (const agent of agents.list()) { const agentHandle = agents.handleOf(agent.agentId); if (agentHandle === undefined) continue; - const state = agentHandle.accessor.get(IAgentActivityView).state(); - if (state.turn !== undefined || state.background.length > 0) return true; + if (agentHandle.accessor.get(IAgentLoopService).status().state === 'running') return true; + const tasks = agentHandle.accessor.get(IAgentTaskService); + if (tasks.list(true).length > 0) return true; + if (agentHandle.accessor.get(IAgentFullCompactionService).compacting !== null) return true; } return false; } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 987021eb042..13ecf9ffa5f 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -274,7 +274,7 @@ export interface BackgroundTaskCompletedEvent { } export interface WaitForCompletedEvent { - outcome: 'completed' | 'timed_out' | 'task_not_found' | 'aborted'; + outcome: 'completed' | 'timed_out' | 'task_not_found' | 'aborted' | 'interrupted'; timeout_ms: number; waited_ms: number; has_task_id: boolean; diff --git a/packages/agent-core-v2/src/features/goal/goalService.ts b/packages/agent-core-v2/src/features/goal/goalService.ts index b94c8d7aefd..32a3235d0c6 100644 --- a/packages/agent-core-v2/src/features/goal/goalService.ts +++ b/packages/agent-core-v2/src/features/goal/goalService.ts @@ -6,6 +6,7 @@ import { createDecorator, IInstantiationService } from '#/_base/di/instantiation import { MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { abortError } from '#/_base/utils/abort'; import { isPlainRecord } from '#/_base/utils/canonical-args'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import { IAgentReminderService } from '#/features/reminder/reminderService'; import { AgentActorService, @@ -47,7 +48,7 @@ import { toKimiErrorPayload, type KimiErrorPayload, } from '#/errors'; -import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ExecutableToolResult } from '#/tool/toolContract'; @@ -497,6 +498,11 @@ function handleTurnLaunched(context: GoalOperationContext, turnId: number, origi context.effects.liveTurnId = turnId; context.effects.goalTurnTargets.delete(turnId); context.effects.exhaustedTurnBudgetGoals.delete(turnId); + const pending = context.effects.pendingContinuation; + if (pending !== undefined && pending.turnId === undefined && isGoalContinuationOrigin(origin)) { + pending.turnId = turnId; + context.effects.pendingContinuationGoals.set(turnId, pending.goalId); + } if (!context.effects.goalDrivenTurns.has(turnId)) { const state = context.runtime.getState().goal; const continuationGoalId = isGoalContinuationOrigin(origin) @@ -725,10 +731,7 @@ function launchContinuationTurn(context: GoalOperationContext, goalId: string, s const { turn } = context.runtime.get(IAgentLoopService).submit({ message }); const pending: PendingContinuation = { turn, goalId }; context.effects.pendingContinuation = pending; - pending.turnId = turn.id; - if (!context.effects.goalDrivenTurns.has(turn.id)) { - context.effects.pendingContinuationGoals.set(turn.id, pending.goalId); - } + void turn.ready.then(() => { pending.turnId = turn.id; }).catch(() => undefined); void turn.result.finally(() => { if (pending.turnId !== undefined) context.effects.pendingContinuationGoals.delete(pending.turnId); if (context.effects.pendingContinuation === pending) context.effects.pendingContinuation = undefined; @@ -884,9 +887,6 @@ function settleWallClock(context: GoalOperationContext, state: GoalState): numbe Math.max(0, context.runtime.get(IGoalDeadlineScheduler).now() - context.effects.liveWallClockStartedAt) ); } - if (state.status === 'active' && state.wallClockResumedAt !== undefined) { - return state.wallClockMs + Math.max(0, Date.now() - state.wallClockResumedAt); - } return state.wallClockMs; } @@ -897,9 +897,6 @@ function liveWallClockMs(context: GoalOperationContext, state: GoalState): numbe Math.max(0, context.runtime.get(IGoalDeadlineScheduler).now() - context.effects.liveWallClockStartedAt) ); } - if (state.status === 'active' && state.wallClockResumedAt !== undefined) { - return state.wallClockMs + Math.max(0, Date.now() - state.wallClockResumedAt); - } return state.wallClockMs; } @@ -948,7 +945,7 @@ function wallClockDeadlineDelay(context: GoalOperationContext): number | undefin budgetMs === undefined || context.effects.liveWallClockStartedAt === undefined ) return undefined; - return Math.max(0, budgetMs - liveWallClockMs(context, state)); + return Math.min(2_147_483_647, Math.max(0, budgetMs - liveWallClockMs(context, state))); } function handleWallClockDeadline(context: GoalOperationContext): void { @@ -1110,6 +1107,12 @@ function createGoalEffectHandlers(runtime: AgentActorContext) isWaitForEnabled: () => isWaitForAvailable(context), }, normalize: () => { normalizeAfterReplay(context); }, + closing: (agent: AgentContext) => { + if (agent !== runtime.agent) return; + const state = runtime.getState().goal; + if (state === null || state.status !== 'active') return; + applyLifecycle(context, state, 'paused', 'Paused after agent closed', 'runtime'); + }, turnStarted: (event: TurnStarted) => { handleTurnLaunched(context, event.turnId, event.origin); }, usageRecorded: (usage: UsageRecordedContext) => { if (usage.agent === runtime.agent) handleUsageRecorded(context, usage); @@ -1185,6 +1188,7 @@ const goalEffects = fromCallback(({ }); const disposables: IDisposable[] = [deadline]; if (input.runtime.agent.agentId === MAIN_AGENT_ID) { + disposables.push(input.runtime.get(IAgentLifecycleService).onWillClose(handlers.closing)); disposables.push(new GoalInjection(handlers.injection, reminderOf(input.runtime))); disposables.push(input.runtime.get(IEventBus).subscribe(TurnStarted, handlers.turnStarted)); disposables.push(input.runtime.get(ISessionUsageService).onDidRecord(handlers.usageRecorded)); diff --git a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md index b20ee5baee9..522d305c228 100644 --- a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md +++ b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md @@ -12,9 +12,9 @@ Do not invent limits. Do not call this for vague wording such as "spend some tim If the user gives a compound time, convert it to one supported unit before calling this tool. For example, "2 hours and 3 minutes" can be set as `value: 123, unit: "minutes"`. -A time budget must be between 1 second and 24 hours — the tool rejects anything shorter or -longer, telling the user it is not a reasonable goal budget. Turn and token budgets are not -bounded this way; they must be positive and are rounded to the nearest whole number (minimum 1). +A time budget must be at least 1 second and convert to a finite number of milliseconds. +There is no upper duration limit. Turn and token budgets must be positive and are rounded +to the nearest whole number (minimum 1). Supported units: diff --git a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts index 1e15fc2ea5b..c26a3d31560 100644 --- a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts +++ b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts @@ -14,7 +14,6 @@ import { } from './set-goal-budget'; const MIN_REASONABLE_TIME_BUDGET_MS = 1_000; -const MAX_REASONABLE_TIME_BUDGET_MS = 24 * 60 * 60 * 1000; export class SetGoalBudgetTool implements ISetGoalBudgetTool { declare readonly _serviceBrand: undefined; @@ -118,7 +117,7 @@ function budgetLimitsFromInput(input: SetGoalBudgetToolInput): GoalBudgetLimits const wallClockBudgetMs = Math.round(toMilliseconds(input.value, input.unit)); if ( wallClockBudgetMs < MIN_REASONABLE_TIME_BUDGET_MS || - wallClockBudgetMs > MAX_REASONABLE_TIME_BUDGET_MS + !Number.isFinite(wallClockBudgetMs) ) { return null; } diff --git a/packages/agent-core-v2/src/features/skill/skillService.ts b/packages/agent-core-v2/src/features/skill/skillService.ts index 49cad63c479..8dfa6f2d2a2 100644 --- a/packages/agent-core-v2/src/features/skill/skillService.ts +++ b/packages/agent-core-v2/src/features/skill/skillService.ts @@ -105,6 +105,10 @@ export class AgentSkillService implements IAgentSkillService { 'Cannot activate skill while another turn is active', ); } + await turn.ready.catch(() => undefined); + if (turn.id === undefined) { + throw new Error2(ErrorCodes.INTERNAL, 'Skill activation turn ended before it started'); + } if (this.scopeContext.agentContext.agentId === MAIN_AGENT_ID) { await applyPromptMetadataUpdate( { @@ -163,6 +167,7 @@ export class AgentSkillService implements IAgentSkillService { if (turn === undefined && handle.state !== 'blocked') { throw new Error2(ErrorCodes.INTERNAL, 'promptWithSkills failed to launch a turn'); } + if (turn !== undefined) await turn.ready.catch(() => undefined); return { turn_id: turn?.id, prompt_id: handle.id, diff --git a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md index ead29be1f66..1fb767bcfb8 100644 --- a/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md +++ b/packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md @@ -11,7 +11,7 @@ Tower runs several agents on one repository at the same time without them steppi Working principles: 1. **Clarify up front. Never block on the human mid-run.** Use `AskUserQuestion` to pin down requirements with the human before you plan and spawn, while ambiguity is still cheap — that is the phase where asking beats deciding. Once the fleet is running, make the reasonable call yourself: record the decision (it lands in the activity log), inform the human in passing, proceed. The return channel is your normal chat reply (the human reads it when they come back) plus `activity.log` — say what you decided and why, in the open. Escalations are reported, not asked — unless every remaining thread is blocked, keep the others moving. Workers and reviewers cannot ask the human at all (their profile has no `AskUserQuestion`); they escalate to you with `TowerSend`. The single mid-run exception is creating git history over a non-empty directory (below): there, ask when asking is possible (not under auto permission mode) and take the safe default when it is not. -2. **Agents negotiate internally.** Workers talk to each other through `TowerSend` directly — questions, review requests, broadcasts (`to: "all"`). You are the coordinator and the only merger, not a content relay: you relay wake-ups (resume an idle agent with a pointer to what it should read), triage findings, untangle conflicts, and merge. +2. **Agents negotiate internally.** Workers talk to each other through `TowerSend` directly — questions, review requests, broadcasts (`to: "all"`). You are the coordinator and the only merger, not a content relay: you relay wake-ups (resume an idle agent with `Agent(resume=..., run_in_background=true, prompt=...)` pointing at what it should read), triage findings, untangle conflicts, and merge. 3. **Scope isolation is real.** `TowerPlan` rejects overlapping scopes, and `TowerMerge` refuses branches that changed files outside their mission scope. Plan scopes carefully; if a mission legitimately needs more, you widen it with `TowerMission` (scope patch — only you can, and it is logged). ## Prepare (only when the directory is not a tower-ready git repo) @@ -22,14 +22,14 @@ Working principles: 1. **Init** — `TowerInit`. It creates `.tower/` and records the base branch — when the human enabled tower mode with `/tower `, the workspace and base branch are already set up, so `TowerInit` just confirms them. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless. When `TowerInit` reports carried-over open missions from a previous session, settle them **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — missions that are neither merged nor abandoned keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them. 2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Write tasks as **verifiable** items a reviewer can map to the diff, and when the human's own words carry intent your paraphrase could lose, copy the key sentences into the mission's `context` **verbatim** — when in doubt, include it. `context` supplements your paraphrase (never replaces it, never holds the full conversation history) and is the one channel that carries the human's voice to both worker and reviewer. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. -3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead. Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers use the configured secondary model when `[secondary_model]` provides one; otherwise they inherit your model. Reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`. +3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead, always in the background (`run_in_background=true`). Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers use the configured secondary model when `[secondary_model]` provides one; otherwise they inherit your model. Reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`. 4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act: - Review request → first reconcile the worker's report against the mission tasks **item by item** (a silently dropped task means the mission is not done — send it back), then `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch) — the briefing hands the reviewer the mission text and the worker's report, so the review verifies intent, not only code health. Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands. - - Review verdict not clean → resume the author (Agent tool) pointing at the review file; the author fixes, pushes, and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope). + - Review verdict not clean → resume the author (`Agent(resume=..., run_in_background=true, prompt=...)`) pointing at the review file; the author fixes, pushes, and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope). - Blocker → answer or reassign if you can; if it genuinely needs the human, inform them and keep the rest moving. - Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human. - Completion report with a suspicious diff (🟢 claimed, zero changed files) → investigate before accepting. -5. **Merge** — `TowerMerge(branch)` in Dependency Flow order. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope — the error message is your next step. After a merge, the result lists branches that now conflict: tell those workers (resume) to rebase onto the new base, resolve, push, and request re-review; their moved tip makes the gate demand a fresh clean review. +5. **Merge** — `TowerMerge(branch)` in Dependency Flow order. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope — the error message is your next step. After a merge, the result lists branches that now conflict: tell those workers (resume with `run_in_background=true`) to rebase onto the new base, resolve, push, and request re-review; their moved tip makes the gate demand a fresh clean review. 6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, call `TowerTeardown` **right away** and report the final summary (missions, merges, review rounds, findings and their disposition). Do not wait for the human to ask: branches and `.tower/comms/` (including the activity log) are kept and dirty worktrees are protected by the tool — only disk is freed. Teardown does **not** exit tower mode — you remain the tower, ready to `TowerInit` the next objective, until the human turns the mode off with `/tower off`. A `/tower teardown` from the human is the same instruction at any earlier point. ## Hard rules for the tower @@ -38,6 +38,7 @@ Working principles: - Never write product code yourself; integration fixes at merge time are yours, everything else goes to a worker. - Mission tracking lives in the tower protocol (`TowerPlan`/`TowerMission`/`TowerStatus`, `MISSIONS.md`), never in `TodoList` — it is code-denied in tower mode because todo semantics (one task in progress at a time) would serialize the fleet. - Workers negotiate through `TowerSend`; you relay wake-ups and step in for conflicts, caps, findings, and merges. +- Every resume of a roster agent is a background call — `Agent(resume=..., run_in_background=true, prompt="...")`, never foreground: you never need a worker's return value inline (its output flows back through the tower protocol files and its completion wakes you), while a foreground resume blocks your whole turn and jams the fleet. The one exception: if your tool set lacks background execution (the Agent tool rejects `run_in_background=true` when TaskList, TaskOutput, or TaskStop is inactive), a foreground resume is the only form the tool accepts — use it there rather than leaving the agent unrecovered. - Never hand-edit `.tower/` files. The tools are the protocol. - You perform every merge, through `TowerMerge` — never `git merge` by hand, never merge around a refusal. - Before `TowerTeardown`, summarize what every worker produced for the human — per mission: what was built, the branch and its merge outcome, and anything left undone. Never tear down without that summary. diff --git a/packages/agent-core-v2/src/features/tower/protocol/frontmatter.ts b/packages/agent-core-v2/src/features/tower/protocol/frontmatter.ts index 1940ae39575..010593c5b93 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/frontmatter.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/frontmatter.ts @@ -1,8 +1,9 @@ const FENCE = '---'; -export function renderFrontmatter(fields: Readonly>): string { +export function renderFrontmatter(fields: Readonly>): string { const lines = [FENCE]; for (const [key, value] of Object.entries(fields)) { + if (value === undefined) continue; if (/[\r\n]/.test(value)) { throw new Error(`frontmatter value for "${key}" must be single-line`); } diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts index 0fe3ddda838..f47a9acf7d9 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -1,4 +1,6 @@ import { execFile } from 'node:child_process'; +import { realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve } from 'node:path'; const GIT_TIMEOUT_MS = 60_000; @@ -120,16 +122,16 @@ export async function isAncestor(cwd: string, ancestor: string, ref: string): Pr return (await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, ref])) !== null; } -export async function worktreeAdd( +export async function worktreeAdd(cwd: string, path: string, branch: string): Promise { + await git(cwd, ['worktree', 'add', path, branch]); +} + +export async function worktreeAddNewBranch( cwd: string, path: string, branch: string, base: string, ): Promise { - if (await branchExists(cwd, branch)) { - await git(cwd, ['worktree', 'add', path, branch]); - return; - } await git(cwd, ['worktree', 'add', path, '-b', branch, base]); } @@ -137,6 +139,20 @@ export async function worktreeRemove(cwd: string, path: string): Promise { await git(cwd, ['worktree', 'remove', '--force', path]); } +export async function isRegisteredWorktree(repoRoot: string, path: string): Promise { + const gitDir = await tryGit(path, ['rev-parse', '--git-dir']); + if (gitDir === null) return false; + const commonDir = await tryGit(repoRoot, ['rev-parse', '--git-common-dir']); + if (commonDir === null) return false; + const adminRoot = join( + await realpath(resolve(await realpath(repoRoot), commonDir.trim())), + 'worktrees', + ); + const resolved = resolve(await realpath(path), gitDir.trim()); + const inside = relative(adminRoot, resolved); + return inside.length > 0 && !inside.startsWith('..') && !isAbsolute(inside); +} + export async function isWorktreeDirty(path: string): Promise { const status = await tryGit(path, ['status', '--porcelain']); return status !== null && status.trim().length > 0; diff --git a/packages/agent-core-v2/src/features/tower/protocol/paths.ts b/packages/agent-core-v2/src/features/tower/protocol/paths.ts index 9c3aa6b8533..02ec6bf159b 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/paths.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/paths.ts @@ -14,6 +14,10 @@ export const MISSIONS_INDEX = `${COMMS_DIR}/MISSIONS.md`; export const TOWER_NAME = 'tower'; export const BROADCAST_NAME = 'all'; +export function isReservedTowerAgentName(name: string): boolean { + return name === TOWER_NAME || name === BROADCAST_NAME; +} + export function dateStamp(now = new Date()): string { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index eb06d61b89e..d24be4a0060 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { appendFile, mkdir, open, readFile, readdir, rename, writeFile } from 'node:fs/promises'; +import { appendFile, mkdir, open, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import picomatch from 'picomatch'; @@ -18,10 +18,12 @@ import { initRepository, isAncestor, isInsideRepo, + isRegisteredWorktree, isWorktreeDirty, mergeNoFf, tryGit, worktreeAdd, + worktreeAddNewBranch, worktreeRemove, } from './git'; import { @@ -36,6 +38,7 @@ import { STATE_FILE, TOWER_NAME, WORKTREES_DIR, + isReservedTowerAgentName, dateDash, findingFileName, inboxFileName, @@ -140,6 +143,29 @@ function isOpenMission(mission: Pick): boolean { return mission.status !== 'merged' && mission.status !== 'abandoned'; } +function missionNumber(id: string): number { + const n = Number.parseInt(id.replace(/^M/, ''), 10); + return Number.isNaN(n) ? 0 : n; +} + +export function resolveMissionByBranch( + state: TowerState, + branch: string, +): TowerMission | undefined { + let resolved: TowerMission | undefined; + for (const mission of state.missions) { + if (mission.branch !== branch || !isOpenMission(mission)) continue; + if (resolved === undefined || missionNumber(mission.id) > missionNumber(resolved.id)) { + resolved = mission; + } + } + return resolved; +} + +function unownedBranchMessage(branch: string): string { + return `branch "${branch}" exists in git but is not owned by any tower mission (it appeared after planning) — refusing to build the worker on unrelated history; delete or rename that branch if it is stale, or re-plan the mission under a new title`; +} + export async function assertLocalBaseBranch(repoRoot: string, base: string): Promise { if (!(await branchExists(repoRoot, base))) { throw new TowerProtocolError( @@ -369,6 +395,16 @@ export class TowerStore { async registerAgent(entry: TowerRosterEntry): Promise { const state = await this.load(); + if (entry.name.trim().length === 0 || entry.name.trim() !== entry.name) { + throw new TowerProtocolError( + `tower agent name "${entry.name}" must not be blank or carry surrounding whitespace`, + ); + } + if (isReservedTowerAgentName(entry.name)) { + throw new TowerProtocolError( + `tower agent name "${entry.name}" is reserved by the tower protocol — pick a different name`, + ); + } if (this.findAgent(state, entry.name) !== undefined) { throw new TowerProtocolError(`tower agent name "${entry.name}" is already registered`); } @@ -403,7 +439,7 @@ export class TowerStore { agent: agentId, kind: entry.kind, status, - reason: reason === undefined ? undefined : reason.replace(/\s+/g, ' ').slice(0, 200), + reason: reason === undefined ? undefined : reason.replaceAll(/\s+/g, ' ').slice(0, 200), mission: entry.missionId, target: entry.reviewTarget, }, @@ -471,6 +507,23 @@ export class TowerStore { } } } + const takenBranches = new Map( + state.missions.map((m): [string, TowerMission] => [m.branch, m]), + ); + for (const mission of missions) { + const existing = takenBranches.get(mission.branch); + if (existing !== undefined) { + throw new TowerProtocolError( + `mission ${mission.id} branch "${mission.branch}" is already used by ${existing.id} (${existing.status}) "${existing.title}" — change the title so its slug differs; branch-to-mission resolution must stay unambiguous`, + ); + } + if (await branchExists(this.repoRoot, mission.branch)) { + throw new TowerProtocolError( + `mission ${mission.id} branch "${mission.branch}" already exists in git but is not owned by any tower mission — the worker would start on that branch's unrelated history; change the title so its slug differs, or delete/rename the stale branch if it is a leftover`, + ); + } + takenBranches.set(mission.branch, mission); + } this.assertScopesDisjoint([ ...state.missions.filter(isOpenMission), ...missions, @@ -653,9 +706,9 @@ export class TowerStore { to, subject: input.subject, sent_at: new Date().toISOString(), - ...(input.scope !== undefined ? { scope: input.scope } : {}), - ...(input.action !== undefined ? { action: input.action } : {}), - ...(input.consentRef !== undefined ? { consent_ref: input.consentRef } : {}), + scope: input.scope, + action: input.action, + consent_ref: input.consentRef, }); const content = `${frontmatter}\n\n${input.body.trim()}\n`; const baseName = inboxFileName({ from: callerName, to, subject: input.subject }); @@ -758,9 +811,10 @@ export class TowerStore { async submitReview(callerName: string, input: TowerReviewInput): Promise { const state = await this.load(); + let callerEntry: TowerRosterEntry | undefined; if (callerName !== TOWER_NAME) { - const caller = this.findAgent(state, callerName); - if (caller?.kind !== 'reviewer' || caller.reviewTarget !== input.target) { + callerEntry = this.findAgent(state, callerName); + if (callerEntry?.kind !== 'reviewer' || callerEntry.reviewTarget !== input.target) { throw new TowerProtocolError( `agent "${callerName}" is not an assigned reviewer for "${input.target}"`, ); @@ -780,16 +834,23 @@ export class TowerStore { const existing = await this.reviewsFor(input.target); const myRounds = existing.filter((r) => r.reviewer === callerName).length; const round = myRounds + 1; + const seq = await this.nextReviewSeq(); const reviewedCommit = await branchTip(this.repoRoot, input.target); + const reviewMissionId = + callerEntry === undefined + ? resolveMissionByBranch(state, input.target)?.id + : callerEntry.reviewMissionId; const frontmatter = renderFrontmatter({ date: dateDash(), reviewer: callerName, target: input.target, round: String(round), + seq: String(seq), status: input.status, merge: input.merge, reviewed_commit: reviewedCommit, + mission: reviewMissionId, }); const checks = (input.checks ?? []).map((c) => `- [x] ${c}`).join('\n'); const content = [ @@ -840,6 +901,8 @@ export class TowerStore { const { fields } = parseFrontmatter(text); const round = Number.parseInt(fields['round'] ?? '', 10); if (Number.isNaN(round)) continue; + const seq = Number.parseInt(fields['seq'] ?? '', 10); + const { mtimeMs } = await stat(this.abs(rel)); reviews.push({ reviewer: fields['reviewer'] ?? 'unknown', target: fields['target'] ?? target, @@ -849,9 +912,18 @@ export class TowerStore { reviewedCommit: fields['reviewed_commit'] ?? '', date: fields['date'] ?? '', file: rel, + mtimeMs, + seq: Number.isNaN(seq) ? undefined : seq, + mission: fields['mission'], }); } - reviews.sort((a, b) => a.round - b.round); + reviews.sort( + (a, b) => + (a.seq ?? -1) - (b.seq ?? -1) || + a.mtimeMs - b.mtimeMs || + a.round - b.round || + a.file.localeCompare(b.file), + ); return reviews; } @@ -860,20 +932,48 @@ export class TowerStore { return reviews.at(-1); } + private async nextReviewSeq(): Promise { + let files: string[]; + try { + files = await readdir(this.abs(REVIEWS_DIR)); + } catch { + return 1; + } + let max = 0; + for (const file of files.filter((f) => f.startsWith('review-') && f.endsWith('.md'))) { + let text: string; + try { + text = await readFile(this.abs(join(REVIEWS_DIR, file)), 'utf8'); + } catch { + continue; + } + const seq = Number.parseInt(parseFrontmatter(text).fields['seq'] ?? '', 10); + if (!Number.isNaN(seq) && seq > max) max = seq; + } + return max + 1; + } + async merge(branch: string): Promise<{ readonly mergeCommit: string; readonly conflictsWith: ReadonlyArray<{ readonly branch: string; readonly files: readonly string[] }>; readonly noop?: boolean; }> { const state = await this.load(); - const mission = state.missions.find((m) => m.branch === branch); - if (mission === undefined) { - throw new TowerProtocolError(`no tower mission owns branch "${branch}"`); - } const block = async (reason: string, message: string): Promise => { await this.appendLog(TOWER_NAME, 'merge.blocked', { branch, reason }); return new TowerProtocolError(message); }; + const mission = resolveMissionByBranch(state, branch); + if (mission === undefined) { + const closed = state.missions.filter((m) => m.branch === branch); + if (closed.length > 0) { + throw await block( + 'branch-owned-by-closed-missions', + `merge blocked: branch "${branch}" resolves only to closed mission(s) ${closed.map((m) => `${m.id} (${m.status})`).join(', ')} — TowerMerge never flips a closed mission's status; re-plan the work under a new title if it should land`, + ); + } + throw new TowerProtocolError(`no tower mission owns branch "${branch}"`); + } const unmergedDeps = mission.deps.filter((dep) => { const depMission = state.missions.find((m) => m.id === dep); @@ -903,7 +1003,17 @@ export class TowerStore { return { mergeCommit: tip, conflictsWith: [], noop: true }; } - const review = await this.latestReview(branch); + const reviews = await this.reviewsFor(branch); + const siblingMissions = state.missions.filter((m) => m.branch === branch && m.id !== mission.id); + const stamped = reviews.filter((r) => r.mission === mission.id); + const candidates = + stamped.length > 0 + ? reviews.filter( + (r) => + r.mission === mission.id || (r.mission === undefined && siblingMissions.length === 0), + ) + : reviews.filter((r) => r.mission === undefined); + const review = candidates.at(-1); if (review === undefined) { throw await block( 'no-review', @@ -923,6 +1033,12 @@ export class TowerStore { `merge blocked: ${branch} moved since the clean review (reviewed ${review.reviewedCommit.slice(0, 7)}, tip ${tip.slice(0, 7)}) — re-review required`, ); } + if (review.mission === undefined && siblingMissions.length > 0) { + throw await block( + 'review-mission-mismatch', + `merge blocked: "${branch}" is shared with other mission record(s) ${siblingMissions.map((m) => `${m.id} (${m.status})`).join(', ')}, and the latest clean review (round ${review.round} by ${review.reviewer}) predates mission-stamped reviews — re-review ${mission.id} so the gate can tell which mission was audited`, + ); + } const changed = await diffNameOnly(this.repoRoot, await this.diffBase(state, mission), branch); const outOfScope = changed.filter( @@ -998,37 +1114,56 @@ export class TowerStore { async addWorktree(worktree: string, branch: string, base: string): Promise { const rel = join(WORKTREES_DIR, worktree); let spawnBase: string | undefined; - if (!(await branchExists(this.repoRoot, branch))) { - const dirty = await listBaseDirtyEntries(this.repoRoot); - if (dirty.some((entry) => entry.unmerged)) { + if (await branchExists(this.repoRoot, branch)) { + const state = await this.load(); + const mission = state.missions.find((m) => m.worktree === worktree && m.branch === branch); + const registered = await isRegisteredWorktree(this.repoRoot, this.abs(rel)); + const checkedOut = registered + ? await tryGit(this.abs(rel), ['rev-parse', '--abbrev-ref', 'HEAD']) + : null; + if (mission?.owner === undefined && checkedOut?.trim() !== branch) { + throw new TowerProtocolError(unownedBranchMessage(branch)); + } + await worktreeAdd(this.repoRoot, this.abs(rel), branch); + await this.appendLog(TOWER_NAME, 'worktree.add', { worktree, branch, base, spawn_base: spawnBase }); + return { rel, spawnBase }; + } + const dirty = await listBaseDirtyEntries(this.repoRoot); + if (dirty.some((entry) => entry.unmerged)) { + throw new TowerProtocolError( + 'the base checkout has unmerged paths (an in-progress merge, rebase, or cherry-pick) — finish or abort it before spawning workers', + ); + } + if (dirty.length > 0) { + let checkout: string; + try { + checkout = await currentBranch(this.repoRoot); + } catch { throw new TowerProtocolError( - 'the base checkout has unmerged paths (an in-progress merge, rebase, or cherry-pick) — finish or abort it before spawning workers', + `the main checkout is in a detached HEAD state with uncommitted changes, and the recorded base is "${base}" — a WIP snapshot would carry detached-HEAD content into the mission branch; check out "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`, ); } - if (dirty.length > 0) { - let checkout: string; - try { - checkout = await currentBranch(this.repoRoot); - } catch { - throw new TowerProtocolError( - `the main checkout is in a detached HEAD state with uncommitted changes, and the recorded base is "${base}" — a WIP snapshot would carry detached-HEAD content into the mission branch; check out "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`, - ); - } - if (checkout !== base) { - throw new TowerProtocolError( - `the main checkout is on "${checkout}" with uncommitted changes, not the recorded base "${base}" — a WIP snapshot would carry "${checkout}" content into the mission branch; switch back to "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`, - ); - } + if (checkout !== base) { + throw new TowerProtocolError( + `the main checkout is on "${checkout}" with uncommitted changes, not the recorded base "${base}" — a WIP snapshot would carry "${checkout}" content into the mission branch; switch back to "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`, + ); + } + } + spawnBase = + (await snapshotBaseWip( + this.repoRoot, + base, + dirty.map((entry) => entry.path), + `tower: snapshot of uncommitted base checkout changes (worktree ${worktree})`, + )) ?? undefined; + try { + await worktreeAddNewBranch(this.repoRoot, this.abs(rel), branch, spawnBase ?? base); + } catch (error) { + if (await branchExists(this.repoRoot, branch)) { + throw new TowerProtocolError(unownedBranchMessage(branch)); } - spawnBase = - (await snapshotBaseWip( - this.repoRoot, - base, - dirty.map((entry) => entry.path), - `tower: snapshot of uncommitted base checkout changes (worktree ${worktree})`, - )) ?? undefined; + throw error; } - await worktreeAdd(this.repoRoot, this.abs(rel), branch, spawnBase ?? base); await this.appendLog(TOWER_NAME, 'worktree.add', { worktree, branch, base, spawn_base: spawnBase }); return { rel, spawnBase }; } @@ -1039,6 +1174,14 @@ export class TowerStore { for (const mission of state.missions) { const rel = join(WORKTREES_DIR, mission.worktree); const absPath = this.abs(rel); + if (!(await isRegisteredWorktree(this.repoRoot, absPath))) { + report.push(`already removed ${rel}`); + await this.appendLog(TOWER_NAME, 'worktree.remove.skipped', { + worktree: mission.worktree, + reason: 'already-removed', + }); + continue; + } if (await isWorktreeDirty(absPath)) { if (options.force !== true) { report.push(`kept ${rel} (uncommitted changes — rerun with force to remove)`); diff --git a/packages/agent-core-v2/src/features/tower/protocol/types.ts b/packages/agent-core-v2/src/features/tower/protocol/types.ts index 5d8b0ab7f22..f01c52fed30 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/types.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/types.ts @@ -7,6 +7,7 @@ export interface TowerRosterEntry { readonly kind: TowerAgentKind; readonly missionId?: string; readonly reviewTarget?: string; + readonly reviewMissionId?: string; readonly worktree?: string; readonly branch?: string; readonly spawnedAt: string; @@ -78,6 +79,9 @@ export interface TowerReviewInfo { readonly reviewedCommit: string; readonly date: string; readonly file: string; + readonly mtimeMs: number; + readonly seq?: number; + readonly mission?: string; } export interface TowerInboxItem { diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md index 062d5a21b7b..8d0280997b9 100644 --- a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md +++ b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md @@ -1,5 +1,5 @@ Merge a tower mission branch into the base branch (--no-ff). -Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. The scope diff starts from the mission's recorded spawn base while that snapshot commit is still part of the branch's history, so base-checkout WIP captured as a snapshot commit at spawn time is never mistaken for a worker scope violation; once a rebase drops the snapshot (typically because the WIP has since been committed on the base branch), the diff falls back to the base branch. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. +Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. The merge also refuses when every mission record for the branch is closed (abandoned or already merged) — a merge never flips a historical mission's state; if the work must land, re-plan it under a fresh mission title. The scope diff starts from the mission's recorded spawn base while that snapshot commit is still part of the branch's history, so base-checkout WIP captured as a snapshot commit at spawn time is never mistaken for a worker scope violation; once a rebase drops the snapshot (typically because the WIP has since been committed on the base branch), the diff falls back to the base branch. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. The main checkout must be clean for the files the merge touches: if it still has uncommitted changes in any file the merge would overwrite, the merge is refused and nothing is merged — commit or stash those changes first, then retry. This matters when a mission branch carries a snapshot of the checkout's WIP: that WIP merges into the base history, so the checkout must not still hold the same changes uncommitted. diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts index 5438694eb5f..8a1ff5dde34 100644 --- a/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts @@ -52,7 +52,7 @@ export class TowerMergeTool implements ITowerMergeTool { ...conflictsWith.map( (conflict) => `- ${conflict.branch}: ${conflict.files.join(', ')}`, ), - 'Tell each affected worker (Agent resume) to rebase onto the updated base, resolve, push, and request a re-review.', + 'Tell each affected worker (Agent resume with run_in_background=true — never foreground: their output flows back through the tower protocol files) to rebase onto the updated base, resolve, push, and request a re-review.', ); } else { lines.push('The mission is now marked merged. Continue with the remaining missions in Dependency Flow order.'); diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/plan.md b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md index e0abc47ef7d..3301e1578a6 100644 --- a/packages/agent-core-v2/src/features/tower/tools/plan/plan.md +++ b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md @@ -2,4 +2,4 @@ Split the tower goal into missions. Each mission gets an id (M1, M2, …), a bra Write tasks as verifiable check items — the worker ticks them off, the completion report reconciles against them item by item, and the reviewer maps every one to the diff. When the user's own words carry intent your paraphrase could lose, copy the key sentences into `context` verbatim (when in doubt, include it): context supplements your paraphrase, never replaces it, travels with the mission into the worker and reviewer briefings, and is never the full conversation history. -Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), and deps must reference existing mission ids. Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first). +Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), deps must reference existing mission ids, and mission branches must be unique — a title whose slugged branch collides with any existing mission's branch (including abandoned or merged ones) or with an unowned local git branch is rejected, so rename the title and plan again. Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first). diff --git a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts index b125bf118e7..e06dcbd7863 100644 --- a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts @@ -1,8 +1,12 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentTaskService } from '#/agent/task/task'; +import { ISessionEventBus } from '#/app/event/eventBus'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { toInputJsonSchema } from '#/tool/input-schema'; import type { ToolExecution } from '#/tool/toolContract'; +import { BROADCAST_NAME, TOWER_NAME } from '#/features/tower/protocol/index'; +import { TowerInboxSent } from '#/features/tower/towerOps'; import { callerName, newTowerStore, runTowerTool } from '../support'; import DESCRIPTION from './send.md?raw'; import { ITowerSendTool, TowerSendToolInputSchema, type TowerSendToolInput } from './send'; @@ -16,6 +20,8 @@ export class TowerSendTool implements ITowerSendTool { constructor( @ISessionContext private readonly sessionContext: ISessionContext, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ISessionEventBus private readonly sessionBus: ISessionEventBus, + @IAgentTaskService private readonly tasks: IAgentTaskService, ) {} resolveExecution(args: TowerSendToolInput): ToolExecution { @@ -27,15 +33,36 @@ export class TowerSendTool implements ITowerSendTool { const store = newTowerStore(this.sessionContext); const state = await store.load(); const caller = callerName(this.scopeContext.agentId, store, state); + const to = args.to.trim(); const rel = await store.send(caller, { - to: args.to, + to, subject: args.subject, body: args.body, scope: args.scope, action: args.action, consentRef: args.consent_ref, }); - return { output: `message sent to ${args.to}\nfile: ${rel}` }; + if ( + this.sessionBus !== undefined && + caller !== TOWER_NAME && + (to === TOWER_NAME || to === BROADCAST_NAME) + ) { + this.sessionBus.publish(new TowerInboxSent({ from: caller, to, subject: args.subject })); + } + const entry = + caller === TOWER_NAME && to !== TOWER_NAME && to !== BROADCAST_NAME + ? state.roster.agents.find((agent) => agent.name === to) + : undefined; + const undelivered = + entry !== undefined && + this.tasks !== undefined && + !this.tasks + .list(true) + .some((task) => task.kind === 'agent' && task.agentId === entry.agentId); + const note = undelivered + ? `\nnote: ${to} has no running task in this session — the message sits in its inbox until you deliver it with Agent(resume="${entry.agentId}", run_in_background=true, prompt="...")` + : ''; + return { output: `message sent to ${args.to}\nfile: ${rel}${note}` }; }), }; } diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md index 236d06b7f44..4ac0ffabe94 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md @@ -4,4 +4,4 @@ Workers: pass mission_id — the tool creates the mission worktree, marks the mi If the base checkout has uncommitted changes (staged, unstaged, or untracked) when a worker spawns, the tool captures them as a snapshot commit that becomes the mission branch's first commit — the worker starts from HEAD + that WIP instead of plain HEAD. The checkout itself is never touched (nothing is committed, staged, or stashed there), and the merge gate later diffs the branch from that snapshot while it remains part of the branch's history (falling back to the base branch once a rebase drops the snapshot commit), so the WIP is never mistaken for the worker's own scope. Snapshotting requires the main checkout to be on the recorded base branch: WIP sitting on a different branch (or a detached HEAD) belongs to that line of work, so the spawn is refused rather than mixing that content into the base — switch back to the base or commit/stash first. The snapshot only happens when the branch is first created; re-adding an existing branch reuses it as-is. -The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If the name is already registered, resume the existing agent with the Agent tool instead of spawning a duplicate. +The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If the name is already registered, resume the existing agent with Agent(resume=..., run_in_background=true, prompt="...") instead of spawning a duplicate — never foreground: you never need the agent's return value inline; its output flows back through the tower protocol files. diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index a77b937f139..31110a10353 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -14,7 +14,9 @@ import { TowerProtocolError, TowerStore, WORKTREES_DIR, + isReservedTowerAgentName, missionFileName, + resolveMissionByBranch, resolveTowerRepoRoot, type TowerMission, type TowerState, @@ -107,12 +109,26 @@ export class TowerSpawnTool implements ITowerSpawnTool { const store = this.newStore(); const state = await store.load(); + if (args.name.trim().length === 0 || args.name.trim() !== args.name) { + return { + output: `tower agent name "${args.name}" must not be blank or carry surrounding whitespace`, + isError: true, + }; + } + + if (isReservedTowerAgentName(args.name)) { + return { + output: `tower agent name "${args.name}" is reserved by the tower protocol — pick a different name`, + isError: true, + }; + } + const existing = store.findByName(state, args.name); if (existing !== undefined) { return { output: `tower agent "${args.name}" is already registered (agent_id: ${existing.agentId}, kind: ${existing.kind}) — ` + - `resume it instead of spawning a duplicate: Agent(resume="${existing.agentId}", prompt="...")`, + `resume it instead of spawning a duplicate: Agent(resume="${existing.agentId}", run_in_background=true, prompt="...") — never foreground: its output flows back through the tower protocol files`, isError: true, }; } @@ -143,6 +159,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { ); } } catch (error) { + if (error instanceof TowerProtocolError) throw error; notes.push( `worktree setup warning (continuing): ${error instanceof Error ? error.message : String(error)}`, ); @@ -218,6 +235,10 @@ export class TowerSpawnTool implements ITowerSpawnTool { kind: args.kind, missionId: mission?.id, reviewTarget, + reviewMissionId: + reviewTarget !== undefined + ? resolveMissionByBranch(state, reviewTarget)?.id + : undefined, worktree: mission?.worktree, branch: mission?.branch, spawnedAt: new Date().toISOString(), @@ -271,7 +292,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { : [`review_target: ${reviewTarget ?? ''}`]), ...notes, '', - `The ${args.kind} runs detached in the background; its completion arrives as a notification. Track progress with TowerStatus / TowerInbox; recover a dead agent with Agent(resume="${handle.agentId}", prompt="...").`, + `The ${args.kind} runs detached in the background; its completion arrives as a notification. Track progress with TowerStatus / TowerInbox; recover a dead agent with Agent(resume="${handle.agentId}", run_in_background=true, prompt="...") — never foreground: its output flows back through the tower protocol files.`, ].join('\n'), }; } finally { @@ -414,7 +435,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { ); } const target = reviewTarget ?? ''; - const targetMission = state.missions.find((m) => m.branch === target); + const targetMission = resolveMissionByBranch(state, target); const author = targetMission?.owner; const reviewBase = targetMission !== undefined ? await store.diffBase(state, targetMission) : state.base; diff --git a/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts index 3966b700895..69695e52f0a 100644 --- a/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts @@ -187,7 +187,7 @@ function renderDeathWarnings(state: TowerState): string[] { const entry = deadByName.get(mission.owner); if (entry === undefined) continue; lines.push( - `- ⚠️ ${mission.id} owner ${entry.name} died (${entry.deathStatus ?? 'unknown'}) — recover with Agent(resume="${entry.agentId}", prompt="...") or reassign the mission`, + `- ⚠️ ${mission.id} owner ${entry.name} died (${entry.deathStatus ?? 'unknown'}) — recover with Agent(resume="${entry.agentId}", run_in_background=true, prompt="...") (never foreground: its output flows back through the tower protocol files) or reassign the mission`, ); } if (lines.length === 0) return lines; diff --git a/packages/agent-core-v2/src/features/tower/towerOps.ts b/packages/agent-core-v2/src/features/tower/towerOps.ts index 394801feced..01426bc991c 100644 --- a/packages/agent-core-v2/src/features/tower/towerOps.ts +++ b/packages/agent-core-v2/src/features/tower/towerOps.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; -import { AgentEvent2 } from '#/app/event/event2'; +import { AgentEvent2, Event2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; const towerModeEnterSchema = z.object({ @@ -33,6 +33,18 @@ export interface TowerModeExit { readonly agentId: string; } +export interface TowerInboxSentPayload { + readonly from: string; + readonly to: string; + readonly subject: string; +} + +export class TowerInboxSent extends Event2 { + static override readonly type = 'tower.inbox.sent'; + static override readonly observable = true; +} +export interface TowerInboxSent extends TowerInboxSentPayload {} + export const towerKey = defineState('tower', () => false).replayable({ schema: z.boolean(), }) diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts index c2b7834e435..dfb11ec0890 100644 --- a/packages/agent-core-v2/src/features/tower/towerService.ts +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -1,22 +1,24 @@ import { join } from 'node:path'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IAgentReminderService } from '#/features/reminder/reminderService'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentLoopService, type LoopNotifyHandle } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import type { AgentTaskInfo } from '#/agent/task/types'; import { TaskTerminatedNotice } from '#/agent/task/taskOps'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; +import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; import { IFeatureManager } from '#/app/feature/featureManager'; import { LifecycleScope } from '#/app/scopes'; import { IFlagService } from '#/app/flag/flag'; @@ -31,6 +33,8 @@ import { isUntitled } from '#/session/sessionMetadata/promptMetadata'; import { SubagentStarted } from '#/session/subagent/mirrorAgentRun'; import { TowerModeInjection } from './injection/towerModeInjection'; import { + BROADCAST_NAME, + TOWER_NAME, TowerStore, WORKTREES_DIR, assertLocalBaseBranch, @@ -49,10 +53,14 @@ import { type TowerEnterResult, } from './tower'; import { isTowerFeatureAssembled } from './towerFeature'; -import { TowerModeEnter, TowerModeExit, towerBaseKey, towerKey, towerOwnerKey } from './towerOps'; +import { TowerInboxSent, TowerModeEnter, TowerModeExit, towerBaseKey, towerKey, towerOwnerKey } from './towerOps'; export const TOWER_MODE_TOOLS: readonly string[] = ['TowerInit', ...TOWER_TOOL_NAMES]; +export const TOWER_INBOX_WAKE_VARIANT = 'tower_inbox'; + +const WAKE_SUBJECT_PREVIEW_MAX = 120; + export class AgentTowerService extends Disposable implements IAgentTowerService { declare readonly _serviceBrand: undefined; @@ -60,6 +68,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentStateService private readonly agentState: IAgentStateService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, @@ -72,6 +81,8 @@ export class AgentTowerService extends Disposable implements IAgentTowerService @IAgentContextMemoryService context: IAgentContextMemoryService, @IEventBus eventBus: IEventBus, @ILogService private readonly log: ILogService, + @IAgentLoopService private readonly loop: IAgentLoopService, + @ISessionEventBus sessionBus: ISessionEventBus, ) { super(); this.agentState.contributeState(towerKey); @@ -125,6 +136,18 @@ export class AgentTowerService extends Disposable implements IAgentTowerService void this.clearTowerAgentDeath(event.subagentId); }), ); + if (sessionBus !== undefined) { + this._register( + sessionBus.subscribe(TowerInboxSent, (event) => { + this.onTowerInboxSent(event); + }), + ); + } + this._register( + toDisposable(() => { + this.wakeDisposed = true; + }), + ); this._register( toolExecutor.onBeforeExecuteTool((event) => { if (this.flags.enabled(TOWER_FLAG_ID)) return; @@ -152,6 +175,40 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); }), ); + this._register( + toolExecutor.onBeforeExecuteTool(async (event) => { + if (!this.flags.enabled(TOWER_FLAG_ID)) return; + if (!this.isActive) return; + if (event.toolCall.name !== 'Agent') return; + const args = event.args; + if (typeof args !== 'object' || args === null) return; + const resume = (args as { readonly resume?: unknown }).resume; + if (typeof resume !== 'string') return; + const resumeId = resume.trim(); + if (resumeId.length === 0) return; + if ((args as { readonly run_in_background?: unknown }).run_in_background === true) return; + const backgroundAvailable = + this.toolPolicy.isToolActive('TaskList') && + this.toolPolicy.isToolActive('TaskOutput') && + this.toolPolicy.isToolActive('TaskStop'); + if (!backgroundAvailable) return; + const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd)); + const entry = await store + .load() + .then( + (state) => state.roster.agents.find((agent) => agent.agentId === resumeId), + () => undefined, + ); + if (entry === undefined) return; + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + `Resuming tower agent "${entry.name}" in the foreground would freeze the tower until it finishes — pass run_in_background=true instead; its completion (and any inbox traffic) will wake you.`, + ), + ), + ); + }), + ); this._register( toolExecutor.onBeforeExecuteTool(async (event) => { if (this.profile.data().profileName !== TOWER_WORKER_PROFILE) return; @@ -298,10 +355,17 @@ export class AgentTowerService extends Disposable implements IAgentTowerService exit(): void { if (!this.agentState.get(towerKey)) return; this.lastPublished = false; + this.dropInboxWake(); void this.dispatcher.dispatch(new TowerModeExit({ agentId: this.agentCtx.agentId })); void this.releaseTowerOwnership(); } + private dropInboxWake(): void { + this.inboxWakeHandle?.drop(); + this.inboxWakeHandle = undefined; + this.inboxWakeSignals = 0; + } + private async releaseTowerOwnership(): Promise { const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd)); await store.release(this.sessionCtx.sessionId).then( @@ -369,6 +433,72 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); } + private inboxWakeSignals = 0; + private inboxWakeLatest: { readonly from: string; readonly subject: string } | undefined; + private inboxWakeScheduled = false; + private inboxWakePending = false; + private inboxWakeHandle: LoopNotifyHandle | undefined; + private wakeDisposed = false; + + private onTowerInboxSent(event: TowerInboxSent): void { + if (this.agentCtx.agentId !== 'main') return; + if (!this.isActive) return; + if (event.from === TOWER_NAME) return; + if (event.to !== TOWER_NAME && event.to !== BROADCAST_NAME) return; + this.inboxWakeSignals += 1; + this.inboxWakeLatest = { from: event.from, subject: event.subject }; + this.scheduleInboxWake(); + } + + private scheduleInboxWake(): void { + if (this.inboxWakeScheduled || this.inboxWakePending) return; + this.inboxWakeScheduled = true; + queueMicrotask(() => { + this.flushInboxWake(); + }); + } + + private flushInboxWake(): void { + this.inboxWakeScheduled = false; + if (this.wakeDisposed || !this.isActive || this.loop === undefined) { + this.inboxWakeSignals = 0; + return; + } + const count = this.inboxWakeSignals; + const latest = this.inboxWakeLatest; + if (count === 0 || latest === undefined) return; + this.inboxWakeSignals = 0; + this.inboxWakePending = true; + const countText = count === 1 ? '1 new tower inbox message' : `${String(count)} new tower inbox messages`; + const subject = + latest.subject.length > WAKE_SUBJECT_PREVIEW_MAX + ? `${latest.subject.slice(0, WAKE_SUBJECT_PREVIEW_MAX)}…` + : latest.subject; + this.inboxWakeHandle = this.loop.notify({ + message: { + role: 'user', + content: [ + { + type: 'text', + text: `${countText} — latest from ${latest.from}: "${subject}". Read and route with TowerInbox.`, + }, + ], + toolCalls: [], + origin: { kind: 'injection', variant: TOWER_INBOX_WAKE_VARIANT }, + }, + turnScoped: false, + onConsume: () => { + this.inboxWakeHandle = undefined; + this.inboxWakePending = false; + if (this.inboxWakeSignals > 0) this.scheduleInboxWake(); + }, + onDrop: () => { + this.inboxWakeHandle = undefined; + this.inboxWakePending = false; + }, + }); + } + private restoreTowerTools(): void { if (!this.flags.enabled(TOWER_FLAG_ID)) return; if (!this.isActive) return; @@ -387,6 +517,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService return; } const effective = this.isActive; + if (!effective) this.dropInboxWake(); if (this.lastPublished === effective) return; this.lastPublished = effective; void this.dispatcher.dispatch( diff --git a/packages/agent-core-v2/src/human/agent/events.ts b/packages/agent-core-v2/src/human/agent/events.ts new file mode 100644 index 00000000000..9c45f46a046 --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/events.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +import { defineEvent } from '#/eventStore/events'; + +import { historyMessageSchema, systemMessageSchema, userMessageSchema } from './historySchema'; + +export const messageAppended = defineEvent({ + type: 'message.appended', + schema: z.object({ message: historyMessageSchema }), +}); +export type MessageAppended = ReturnType; + +export const turnStarted = defineEvent({ + type: 'turn.started', + schema: z.object({ turnId: z.number().int(), queueItemId: z.string().optional() }), +}); +export type TurnStarted = ReturnType; + +export const turnEnded = defineEvent({ + type: 'turn.ended', + schema: z.object({ + turnId: z.number().int(), + outcome: z.enum(['done', 'failed', 'aborted']), + errorMessage: z.string().optional(), + }), +}); +export type TurnEnded = ReturnType; + +export const inputSubmitted = defineEvent({ + type: 'input.submitted', + schema: z.object({ id: z.string().optional(), message: userMessageSchema }), +}); +export type InputSubmitted = ReturnType; + +export const inputNotified = defineEvent({ + type: 'input.notified', + schema: z.object({ message: userMessageSchema, source: z.string().optional() }), +}); +export type InputNotified = ReturnType; + +export const inputReminded = defineEvent({ + type: 'input.reminded', + schema: z.object({ key: z.string(), message: z.union([userMessageSchema, systemMessageSchema]) }), +}); +export type InputReminded = ReturnType; + +export const inputSteered = defineEvent({ + type: 'input.steered', + schema: z.object({ id: z.string(), message: userMessageSchema }), +}); +export type InputSteered = ReturnType; + +export const inputCancelled = defineEvent({ + type: 'input.cancelled', + schema: z.object({ id: z.string() }), +}); +export type InputCancelled = ReturnType; + +export const queueDrained = defineEvent({ + type: 'queue.drained', + schema: z.object({ id: z.string().optional() }), +}); +export type QueueDrained = ReturnType; + +export const inputDrained = defineEvent({ type: 'input.drained', schema: z.object({}) }); +export type InputDrained = ReturnType; + +export const notificationsDrained = defineEvent({ type: 'notifications.drained', schema: z.object({}) }); +export type NotificationsDrained = ReturnType; + +export const stateUpdated = defineEvent({ + type: 'state.updated', + schema: z.object({ name: z.string(), value: z.unknown() }), +}); +export type StateUpdated = ReturnType; diff --git a/packages/agent-core-v2/src/human/agent/historySchema.ts b/packages/agent-core-v2/src/human/agent/historySchema.ts new file mode 100644 index 00000000000..07b6324b1b6 --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/historySchema.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; + +import type { HistoryMessage } from './turn'; + +const textPartSchema = z.object({ type: z.literal('text'), text: z.string() }); +const thinkPartSchema = z.object({ + type: z.literal('think'), + think: z.string(), + encrypted: z.string().optional(), + detailsIndex: z.number().optional(), +}); +const imageUrlPartSchema = z.object({ + type: z.literal('image_url'), + imageUrl: z.object({ url: z.string(), id: z.string().optional(), name: z.string().optional() }), +}); +const audioUrlPartSchema = z.object({ + type: z.literal('audio_url'), + audioUrl: z.object({ url: z.string(), id: z.string().optional() }), +}); +const videoUrlPartSchema = z.object({ + type: z.literal('video_url'), + videoUrl: z.object({ url: z.string(), id: z.string().optional(), name: z.string().optional() }), +}); + +export const contentPartSchema = z.discriminatedUnion('type', [ + textPartSchema, + thinkPartSchema, + imageUrlPartSchema, + audioUrlPartSchema, + videoUrlPartSchema, +]); + +const toolDescriptionSchema = z.object({ + name: z.string(), + description: z.string(), + parameters: z.record(z.string(), z.unknown()), + deferred: z.literal(true).optional(), +}); + +const toolCallSchema = z.object({ + type: z.literal('function'), + id: z.string(), + name: z.string(), + arguments: z.string().nullable(), + extras: z.record(z.string(), z.unknown()).optional(), + rawId: z.string().optional(), + _streamIndex: z.union([z.number(), z.string()]).optional(), +}); + +export const systemMessageSchema = z.object({ + role: z.literal('system'), + content: z.array(contentPartSchema), + tools: z.array(toolDescriptionSchema).optional(), +}); + +export const userMessageSchema = z.object({ + role: z.literal('user'), + content: z.array(contentPartSchema), +}); + +export const assistantMessageSchema = z.object({ + role: z.literal('assistant'), + content: z.array(contentPartSchema), + toolCalls: z.array(toolCallSchema), +}); + +export const toolMessageSchema = z.object({ + role: z.literal('tool'), + content: z.array(contentPartSchema), + toolCallId: z.string(), +}); + +const tokenUsageSchema = z.object({ + inputOther: z.number(), + output: z.number(), + inputCacheRead: z.number(), + inputCacheCreation: z.number(), + raw: z.record(z.string(), z.unknown()).optional(), +}); + +const finishInfoSchema = z.object({ + finishReason: z.enum(['completed', 'tool_calls', 'truncated', 'filtered', 'paused', 'other']).nullable(), + rawFinishReason: z.string().nullable(), +}); + +const entryMetaSchema = z.object({ source: z.string().optional(), key: z.string().optional() }); + +const assistantMetaSchema = entryMetaSchema.extend({ + model: z.object({ provider: z.string(), model: z.string() }).optional(), + usage: tokenUsageSchema, + headers: z.record(z.string(), z.string()).optional(), + finish: finishInfoSchema.optional(), + messageId: z.string().optional(), +}); + +export const historyMessageSchema = z.union([ + z.object({ message: systemMessageSchema, meta: entryMetaSchema }), + z.object({ message: userMessageSchema, meta: entryMetaSchema }), + z.object({ message: assistantMessageSchema, meta: assistantMetaSchema }), + z.object({ message: toolMessageSchema, meta: entryMetaSchema }), +]) as z.ZodType; diff --git a/packages/agent-core-v2/src/human/agent/machine.ts b/packages/agent-core-v2/src/human/agent/machine.ts index cdd24925cd4..015e1681168 100644 --- a/packages/agent-core-v2/src/human/agent/machine.ts +++ b/packages/agent-core-v2/src/human/agent/machine.ts @@ -1,11 +1,6 @@ import { assign, emit, enqueueActions, fromCallback, sendTo, setup } from '#/xstate2'; -import { - createUserMessage, - type SystemMessage, - type ToolCall, - type UserMessage, -} from '#/llm/message'; +import { createUserMessage, type SystemMessage, type ToolCall, type UserMessage } from '#/llm/message'; import type { LlmRequestConfig } from '#/llm/requester/requester'; import type { ToolExecutor, ToolResult } from '#/tool/executor'; import { createToolMachine, type ToolEvent, type ToolOutput } from '#/tool/machine'; @@ -13,21 +8,30 @@ import type { ToolDefinition } from '#/tool/tool'; import { createWaitForTasks, type ToolActorRef } from './wait-for'; import { interruptReasonOf, type TurnInterruptReason } from './errors'; +import { + inputCancelled, + inputDrained, + inputNotified, + inputReminded, + inputSteered, + inputSubmitted, + messageAppended, + notificationsDrained, + queueDrained, + turnEnded, + turnStarted, +} from './events'; import { createSystemEntry, createUserEntry } from './turn'; import { createAbortScope, withAbort, type AbortScope } from '#/utils/abort'; -import type { - createTurnMachine, - HistoryMessage, - TurnLlmEvent, - TurnOutput, - UserEntry, -} from './turn'; +import type { createTurnMachine, HistoryMessage, TurnLlmEvent, TurnOutput, UserEntry } from './turn'; +import { storeActor } from '#/eventStore/actor'; +import type { AgentEventStore, AgentStoreState, QueuedPrompt } from './slices'; + +export type { QueuedPrompt } from './slices'; export interface AgentInput { request: LlmRequestConfig; - history?: readonly HistoryMessage[]; - turnId?: number; - branchId?: string; + store: AgentEventStore; } export type AgentEvent = @@ -37,16 +41,22 @@ export type AgentEvent = | { type: 'input.notify'; message: UserMessage } | { type: 'input.remind'; key: string; message: UserMessage | SystemMessage } | { type: 'input.steer'; id: string } + | { type: 'input.cancel'; id: string } | { type: 'input.abort' } | { type: 'turn.spawn_tools'; toolCalls: ToolCall[] } | { type: 'turn.drain' } | { type: 'turn.reminders_consumed'; reminders: HistoryMessage[] } - | { type: 'context.reset'; history: readonly HistoryMessage[]; turnId: number; branchId?: string }; + | { type: 'step.started'; step: number } + | { type: 'store.ready'; state: AgentStoreState; branch: string } + | { type: 'store.changed'; state: AgentStoreState } + | { type: 'store.reset'; state: AgentStoreState; branch: string } + | { type: 'store.error'; error: unknown }; export type AgentEmitted = | TurnLlmEvent | ToolEvent - | { type: 'turn.started'; turnId: number; branchId: string } + | { type: 'turn.started'; turnId: number; branchId: string; queueItemId?: string } + | { type: 'step.started'; step: number } | { type: 'turn.aborting' } | { type: 'turn.reminders_consumed'; reminders: HistoryMessage[] } | { type: 'turn.done'; messages: HistoryMessage[]; branchId: string } @@ -66,11 +76,6 @@ interface ToolEntry { ref: ToolActorRef; } -export interface QueuedPrompt { - id?: string; - message: UserMessage; -} - export interface AgentMachineContext { input: AgentInput; messages: HistoryMessage[]; @@ -81,7 +86,9 @@ export interface AgentMachineContext { reminders: HistoryMessage[]; queue: QueuedPrompt[]; turnId: number; + activeTurnId?: number; branchId: string; + drainedId?: string; } function completionNotification(toolCall: ToolCall, output: ToolOutput): UserEntry { @@ -168,7 +175,7 @@ function hasBackgroundWork(context: AgentMachineContext): boolean { function drainPendingPatch( context: AgentMachineContext, -): Pick { +): Pick { const [head, ...rest] = context.queue; return { messages: [ @@ -178,21 +185,19 @@ function drainPendingPatch( ], notifications: [], queue: rest, + drainedId: head?.id, }; } -function steerPatch( - context: AgentMachineContext, - id: string, -): Partial> { - const index = context.queue.findIndex((entry) => entry.id === id); - if (index === -1) { - return {}; - } - const entry = context.queue[index] as QueuedPrompt; +function mirrorPatch(state: AgentStoreState): Pick< + AgentMachineContext, + 'messages' | 'queue' | 'notifications' | 'reminders' +> { return { - queue: context.queue.filter((_, i) => i !== index), - notifications: [...context.notifications, createUserEntry(entry.message, { source: 'input' })], + messages: [...state.history], + queue: [...state.queue], + notifications: [...state.notifications], + reminders: [...state.reminders], }; } @@ -242,6 +247,7 @@ export function createAgentMachine({ actors: { turnActor, toolActor: createToolMachine(executor), + storeActor, controllerGuard: fromCallback( ({ input }) => () => @@ -252,6 +258,22 @@ export function createAgentMachine({ forwardToParent: ({ self, event }) => { self._parent?.send(event); }, + resetMirror: assign(({ event }) => { + if (event.type !== 'store.reset') return {}; + return { + ...mirrorPatch(event.state), + turnTools: {}, + background: {}, + scope: createAbortScope(), + turnId: event.state.turnIndex.nextTurnId, + activeTurnId: undefined, + branchId: event.branch, + }; + }), + emitReset: emit(({ context }) => ({ type: 'context.reset' as const, branchId: context.branchId })), + abortScope: ({ context }) => { + context.scope.abort(); + }, spawnTurnTools: assign(({ context, spawn, self, event }) => { if (event.type !== 'turn.spawn_tools') { return {}; @@ -302,53 +324,116 @@ export function createAgentMachine({ }, }).createMachine({ id: 'agent', - initial: 'idle', + initial: 'restoring', context: ({ input }) => ({ input, - messages: [...(input.history ?? [])], + messages: [], turnTools: {}, background: {}, scope: createAbortScope(), notifications: [], reminders: [], queue: [], - turnId: input.turnId ?? 0, - branchId: input.branchId ?? 'main', + turnId: 0, + branchId: 'main', }), - invoke: { - src: 'controllerGuard', - input: ({ context }) => ({ scope: context.scope }), - }, + invoke: [ + { + src: 'controllerGuard', + input: ({ context }) => ({ scope: context.scope }), + }, + { + id: 'store', + src: 'storeActor', + input: ({ context }) => ({ store: context.input.store }), + }, + ], on: { 'input.submit': { - actions: assign({ - queue: ({ context, event }) => [ - ...context.queue, - { id: event.id, message: event.message }, - ], - }), + actions: [ + assign(({ context, event }) => { + if (event.type !== 'input.submit') return {}; + return { queue: [...context.queue, { id: event.id, message: event.message }] }; + }), + sendTo('store', ({ event }) => ({ + type: 'store.append' as const, + event: inputSubmitted({ id: event.id, message: event.message }), + })), + ], }, 'input.notify': { - actions: assign({ - notifications: ({ context, event }) => [ - ...context.notifications, - createUserEntry(event.message, { source: 'notify' }), - ], - }), + actions: [ + assign(({ context, event }) => { + if (event.type !== 'input.notify') return {}; + return { + notifications: [ + ...context.notifications, + createUserEntry(event.message, { source: 'notify' }), + ], + }; + }), + sendTo('store', ({ event }) => ({ + type: 'store.append' as const, + event: inputNotified({ message: event.message }), + })), + ], }, 'input.remind': { - actions: assign({ - reminders: ({ context, event }) => [ - ...context.reminders.filter((entry) => entry.meta.key !== event.key), - event.message.role === 'system' - ? createSystemEntry(event.message, { source: 'reminder', key: event.key }) - : createUserEntry(event.message, { source: 'reminder', key: event.key }), - ], - }), + actions: [ + assign(({ context, event }) => { + if (event.type !== 'input.remind') return {}; + const kept = context.reminders.filter((entry) => entry.meta.key !== event.key); + kept.push( + event.message.role === 'system' + ? createSystemEntry(event.message, { source: 'reminder', key: event.key }) + : createUserEntry(event.message, { source: 'reminder', key: event.key }), + ); + return { reminders: kept }; + }), + sendTo('store', ({ event }) => ({ + type: 'store.append' as const, + event: inputReminded({ key: event.key, message: event.message }), + })), + ], }, 'input.steer': { - actions: assign(({ context, event }) => steerPatch(context, event.id)), + actions: enqueueActions(({ context, event, enqueue }) => { + if (event.type !== 'input.steer') return; + const entry = context.queue.find((item) => item.id === event.id); + if (entry === undefined) return; + enqueue.assign({ + queue: context.queue.filter((item) => item.id !== event.id), + notifications: [ + ...context.notifications, + createUserEntry(entry.message, { source: 'input' }), + ], + }); + enqueue.sendTo('store', { + type: 'store.append' as const, + event: inputSteered({ id: event.id, message: entry.message }), + }); + }), + }, + 'input.cancel': { + actions: [ + assign(({ context, event }) => { + if (event.type !== 'input.cancel') return {}; + return { queue: context.queue.filter((item) => item.id !== event.id) }; + }), + sendTo('store', ({ event }) => ({ + type: 'store.append' as const, + event: inputCancelled({ id: event.id }), + })), + ], + }, + 'store.reset': { + target: '.idle', + actions: ['abortScope', 'resetMirror', 'emitReset', 'forwardToParent'], + }, + 'store.error': { + actions: 'forwardToParent', }, + 'store.changed': {}, 'tool.update': { actions: [emit(({ event }) => event), 'forwardToParent'], }, @@ -370,25 +455,42 @@ export function createAgentMachine({ }, }, states: { + restoring: { + on: { + 'store.ready': { + target: 'idle', + actions: assign(({ event }) => ({ + ...mirrorPatch(event.state), + turnId: event.state.turnIndex.nextTurnId, + branchId: event.branch, + })), + }, + }, + }, idle: { initial: 'ready', always: { guard: ({ context }) => hasPendingWork(context), target: 'running', - actions: assign(({ context }) => drainPendingPatch(context)), - }, - on: { - 'context.reset': { - actions: [ - assign(({ context, event }) => ({ - messages: [...event.history], - turnId: event.turnId, - branchId: event.branchId ?? context.branchId, - })), - emit(({ context }) => ({ type: 'context.reset' as const, branchId: context.branchId })), - 'forwardToParent', - ], - }, + actions: [ + sendTo('store', ({ context }) => { + const head = context.queue[0]; + return { + type: 'store.append' as const, + event: [ + ...context.notifications.map((entry) => messageAppended({ message: entry })), + ...(head === undefined + ? [] + : [ + messageAppended({ message: createUserEntry(head.message, { source: 'input' }) }), + queueDrained({ id: head.id }), + ]), + ...(context.notifications.length === 0 ? [] : [notificationsDrained({})]), + ], + }; + }), + assign(({ context }) => drainPendingPatch(context)), + ], }, states: { ready: { @@ -415,35 +517,83 @@ export function createAgentMachine({ actions: [ assign(({ context, event }) => turnOutputPatch(context, event.output)), emit(({ context, event }) => turnOutcomeEvent(context, event.output)), + sendTo('store', ({ context, event }) => ({ + type: 'store.append' as const, + event: [ + ...event.output.produced.map((message) => messageAppended({ message })), + turnEnded({ + turnId: context.activeTurnId ?? context.turnId, + outcome: event.output.type, + errorMessage: + event.output.type === 'failed' ? String(event.output.error) : undefined, + }), + ], + })), ], }, onError: { target: '#agent.idle', - actions: emit(({ context, event }) => ({ - type: 'turn.failed' as const, - error: event.error, - messages: context.messages, - interruptReason: interruptReasonOf(event.error), - branchId: context.branchId, - })), + actions: [ + emit(({ context, event }) => ({ + type: 'turn.failed' as const, + error: event.error, + messages: context.messages, + interruptReason: interruptReasonOf(event.error), + branchId: context.branchId, + })), + sendTo('store', ({ context, event }) => ({ + type: 'store.append' as const, + event: turnEnded({ + turnId: context.activeTurnId ?? context.turnId, + outcome: 'failed', + errorMessage: String(event.error), + }), + })), + ], }, }, entry: [ + assign({ activeTurnId: ({ context }) => context.turnId }), + emit(({ context }) => ({ + type: 'turn.started' as const, + turnId: context.turnId, + branchId: context.branchId, + queueItemId: context.drainedId, + })), + sendTo('store', ({ context }) => ({ + type: 'store.append' as const, + event: turnStarted({ turnId: context.turnId, queueItemId: context.drainedId }), + })), + ], + exit: [ + 'abortTurnTools', + 'stopTurnTools', + assign({ turnTools: {} }), assign({ turnId: ({ context }) => context.turnId + 1 }), - emit(({ context }) => ({ type: 'turn.started' as const, turnId: context.turnId, branchId: context.branchId })), ], - exit: assign({ turnTools: {} }), initial: 'active', on: { - 'turn.drain': { + 'store.reset': { + target: '#agent.idle', actions: [ - sendTo('turn', ({ context }) => ({ - type: 'turn.notify' as const, - messages: [...context.notifications, ...context.reminders], - })), - assign({ notifications: [], reminders: [] }), + 'abortScope', + 'resetMirror', + 'emitReset', + 'forwardToParent', ], }, + 'turn.drain': { + actions: enqueueActions(({ context, enqueue }) => { + const messages = [...context.notifications, ...context.reminders]; + enqueue.sendTo('turn', { type: 'turn.notify' as const, messages }); + if (messages.length === 0) return; + enqueue.assign({ notifications: [], reminders: [] }); + enqueue.sendTo('store', { + type: 'store.append' as const, + event: inputDrained({}), + }); + }), + }, 'tool.detached': { guard: ({ context, event }) => context.turnTools[event.toolCallId] !== undefined, actions: [ @@ -494,6 +644,9 @@ export function createAgentMachine({ 'llm.sent': { actions: [emit(({ event }) => event), 'forwardToParent'], }, + 'step.started': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, 'llm.streaming.*': { actions: [emit(({ event }) => event), 'forwardToParent'], }, @@ -524,11 +677,7 @@ export function createAgentMachine({ }, 'input.abort': { target: 'aborting', - actions: [ - 'abortTurn', - 'abortTurnTools', - emit({ type: 'turn.aborting' as const }), - ], + actions: ['abortTurn', 'abortTurnTools', emit({ type: 'turn.aborting' as const })], }, }, }, diff --git a/packages/agent-core-v2/src/human/agent/replay.ts b/packages/agent-core-v2/src/human/agent/replay.ts deleted file mode 100644 index e0c0955ef56..00000000000 --- a/packages/agent-core-v2/src/human/agent/replay.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { HistoryMessage } from '#/agent/turn'; -import type { Tree } from '#/store/tree'; - -export type TurnOutcome = 'done' | 'failed' | 'aborted'; - -export type TurnEntryData = - | { phase: 'start'; turnId: number } - | { phase: 'end'; turnId?: number; outcome: TurnOutcome }; - -export interface StateEntryData { - name: string; - value: unknown; -} - -export interface LoadedAgentState { - messages: HistoryMessage[]; - turnId: number; - states: Record; -} - -export async function loadAgentState(tree: Tree, branch: string): Promise { - const loadedBranch = tree.openBranch(branch); - const entries = [...loadedBranch.walk()].toReversed(); - const messages: HistoryMessage[] = []; - let turnId = 0; - const states: Record = {}; - for (const entry of entries) { - const data = await tree.resolve(entry); - if (entry.type === 'message') { - messages.push(data as HistoryMessage); - } else if (entry.type === 'turn') { - const turn = data as TurnEntryData; - if (turn.phase === 'start') turnId = Math.max(turnId, turn.turnId); - } else if (entry.type === 'state') { - const state = data as StateEntryData; - states[state.name] = state.value; - } - } - return { messages, turnId, states }; -} diff --git a/packages/agent-core-v2/src/human/agent/slices.ts b/packages/agent-core-v2/src/human/agent/slices.ts new file mode 100644 index 00000000000..7e10d1910a4 --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/slices.ts @@ -0,0 +1,129 @@ +import type { CombinedState, EventStore } from '#/eventStore/eventStore'; +import { createSlice } from '#/eventStore/slice'; +import type { BranchRef } from '#/store/types'; +import type { UserMessage } from '#/llm/message'; + +import { + inputCancelled, + inputDrained, + inputNotified, + inputReminded, + inputSteered, + inputSubmitted, + messageAppended, + notificationsDrained, + queueDrained, + turnEnded, + turnStarted, + type InputCancelled, + type InputNotified, + type InputReminded, + type InputSteered, + type InputSubmitted, + type MessageAppended, + type TurnEnded, + type TurnStarted, +} from './events'; +import { createSystemEntry, createUserEntry, type HistoryMessage, type UserEntry } from './turn'; + +export interface QueuedPrompt { + id?: string; + message: UserMessage; +} + +export const historySlice = createSlice({ + name: 'history', + initialState: () => [] as HistoryMessage[], + reducers: { + [messageAppended.type]: (draft, event: MessageAppended) => { + draft.push(event.message); + }, + }, +}); + +export const queueSlice = createSlice({ + name: 'queue', + initialState: () => [] as QueuedPrompt[], + reducers: { + [inputSubmitted.type]: (draft, event: InputSubmitted) => { + draft.push({ id: event.id, message: event.message }); + }, + [inputCancelled.type]: (draft, event: InputCancelled) => + draft.filter((entry) => entry.id !== event.id), + [inputSteered.type]: (draft, event: InputSteered) => + draft.filter((entry) => entry.id !== event.id), + [queueDrained.type]: (draft) => { + draft.shift(); + }, + }, +}); + +export const notificationsSlice = createSlice({ + name: 'notifications', + initialState: () => [] as UserEntry[], + reducers: { + [inputNotified.type]: (draft, event: InputNotified) => { + draft.push(createUserEntry(event.message, { source: event.source ?? 'notify' })); + }, + [inputSteered.type]: (draft, event: InputSteered) => { + draft.push(createUserEntry(event.message, { source: 'input' })); + }, + [inputDrained.type]: () => [], + [notificationsDrained.type]: () => [], + }, +}); + +export const remindersSlice = createSlice({ + name: 'reminders', + initialState: () => [] as HistoryMessage[], + reducers: { + [inputReminded.type]: (draft, event: InputReminded) => { + const kept = draft.filter((entry) => entry.meta.key !== event.key); + kept.push( + event.message.role === 'system' + ? createSystemEntry(event.message, { source: 'reminder', key: event.key }) + : createUserEntry(event.message, { source: 'reminder', key: event.key }), + ); + return kept; + }, + [inputDrained.type]: () => [], + }, +}); + +export interface TurnIndexEntry { + turnId: number; + start: BranchRef; + end?: BranchRef; +} + +export interface TurnIndexState { + turns: TurnIndexEntry[]; + nextTurnId: number; +} + +export const turnIndexSlice = createSlice({ + name: 'turnIndex', + initialState: (): TurnIndexState => ({ turns: [], nextTurnId: 0 }), + reducers: { + [turnStarted.type]: (draft, event: TurnStarted, ctx) => { + draft.turns.push({ turnId: event.turnId, start: ctx.ref }); + }, + [turnEnded.type]: (draft, event: TurnEnded, ctx) => { + const entry = draft.turns.findLast((turn) => turn.turnId === event.turnId); + if (entry !== undefined) entry.end = ctx.ref; + draft.nextTurnId = event.turnId + 1; + }, + }, +}); + +export const agentSlices = { + history: historySlice, + queue: queueSlice, + notifications: notificationsSlice, + reminders: remindersSlice, + turnIndex: turnIndexSlice, +}; + +export type AgentSlices = typeof agentSlices; +export type AgentEventStore = EventStore; +export type AgentStoreState = CombinedState; diff --git a/packages/agent-core-v2/src/human/agent/turn.ts b/packages/agent-core-v2/src/human/agent/turn.ts index 3a35f53b00c..8566dd046d5 100644 --- a/packages/agent-core-v2/src/human/agent/turn.ts +++ b/packages/agent-core-v2/src/human/agent/turn.ts @@ -1,8 +1,9 @@ import { assign, raise, setup } from '#/xstate2'; import { emptyResponseError } from '#/llm/empty-response'; -import type { LlmErrorMessage, LlmRemoteErrorMessage } from '#/llm/errors'; +import type { LlmErrorMessage } from '#/llm/errors'; import { NO_FINISH, type FinishInfo } from '#/llm/finish-reason'; +import { credentialsRecovery } from '#/credentials/credentials'; import { createMessageAccumulator, createToolMessage, @@ -16,14 +17,14 @@ import { type UserMessage, } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; -import type { createLlmMachine, LlmEvent } from '#/llm/requester/machine'; +import { createRequestActor, type LlmEvent, type MessageResolver } from '#/llm/requester/actor'; import type { LlmRecovery, LlmRecoveryContext, LlmRecoveryProposal, LlmRecoveryRecord, } from '#/llm/requester/recovery'; -import type { LlmRequestConfig } from '#/llm/requester/requester'; +import type { LlmRequestConfig, LlmRequester } from '#/llm/requester/requester'; import { readRetryAfterMs, resolveMaxAttempts, @@ -187,13 +188,19 @@ export type TurnEvent = | LlmEvent | TurnToolEvent | { type: 'turn.notify'; messages: HistoryMessage[] } - | { type: 'turn.abort' }; + | { type: 'turn.abort' } + | { + type: 'turn.failure.triaged'; + cause: Extract; + proposal?: LlmRecoveryProposal & LlmRecoveryRecord; + }; export type TurnLlmEvent = | Exclude | { type: 'llm.done'; entry: AssistantEntry }; export type TurnSignal = + | { type: 'step.started'; step: number } | { type: 'turn.spawn_tools'; toolCalls: ToolCall[] } | { type: 'turn.drain' } | { type: 'turn.reminders_consumed'; reminders: HistoryMessage[] }; @@ -212,10 +219,11 @@ export interface TurnMachineContext { pendingToolCalls: ToolCall[]; outcomes: Record; steps: number; + step: number; attempt: number; delayMs: number; appliedRecoveries: LlmRecoveryRecord[]; - lastError?: LlmRemoteErrorMessage; + recoveryMessages?: readonly Message[]; outcome?: 'done' | 'failed' | 'aborted'; error?: unknown; } @@ -278,30 +286,23 @@ function baseMessages(context: TurnMachineContext): readonly Message[] { return toInputMessages([...context.input.history, ...context.produced]); } -function attemptMessages( - context: TurnMachineContext, - recovery: LlmRecovery | undefined, -): readonly Message[] { - const base = baseMessages(context); - const lastError = context.lastError; - if (lastError === undefined || context.appliedRecoveries.length === 0) return base; - return ( - proposeRecovery(recovery, { - error: lastError, - messages: base, - applied: context.appliedRecoveries.slice(0, -1), - })?.messages ?? base - ); +function attemptMessages(context: TurnMachineContext): readonly Message[] { + return context.recoveryMessages ?? baseMessages(context); } function proposeRecovery( recovery: LlmRecovery | undefined, ctx: LlmRecoveryContext, ): (LlmRecoveryProposal & LlmRecoveryRecord) | undefined { + const credentialProposal = credentialsRecovery.propose(ctx); + if (credentialProposal !== undefined) { + return { strategy: credentialsRecovery.id, ...credentialProposal }; + } if (recovery === undefined) return undefined; const proposal = recovery.propose(ctx); - if (proposal === undefined || proposal.messages === ctx.messages) return undefined; - return { strategy: recovery.id, action: proposal.action, messages: proposal.messages }; + if (proposal === undefined) return undefined; + if (proposal.messages !== undefined && proposal.messages === ctx.messages) return undefined; + return { strategy: recovery.id, ...proposal }; } function llmRetryingEvent( @@ -345,10 +346,11 @@ export interface CreateTurnMachineOptions { readonly recovery?: LlmRecovery; readonly retry?: LlmRetryOptions; readonly abortGraceMs?: number; + readonly messageResolvers?: readonly MessageResolver[]; } export function createTurnMachine( - llmActor: ReturnType, + requester: LlmRequester, options?: CreateTurnMachineOptions, ) { const recovery = options?.recovery; @@ -362,7 +364,7 @@ export function createTurnMachine( output: {} as TurnOutput, }, actors: { - llmActor, + llmActor: createRequestActor(requester, options?.messageResolvers), }, actions: { forwardToParent: ({ self, event }) => { @@ -414,6 +416,7 @@ export function createTurnMachine( pendingToolCalls: [], outcomes: {}, steps: 1, + step: 0, attempt: 1, delayMs: 0, appliedRecoveries: [], @@ -421,14 +424,21 @@ export function createTurnMachine( }, states: { thinking: { - entry: assign({ - accumulator: ({ context }) => - createHistoryAccumulator(modelMeta(context.input.request.model), context.toolCallIds), - llmScope: ({ context }) => - context.input.parentSignal !== undefined - ? withAbort(context.input.parentSignal) - : createAbortScope(), - }), + entry: [ + assign({ + accumulator: ({ context }) => + createHistoryAccumulator(modelMeta(context.input.request.model), context.toolCallIds), + llmScope: ({ context }) => + context.input.parentSignal !== undefined + ? withAbort(context.input.parentSignal) + : createAbortScope(), + step: ({ context }) => context.step + 1, + }), + { + type: 'signalParent', + params: ({ context }) => ({ type: 'step.started' as const, step: context.step }), + }, + ], invoke: { src: 'llmActor', input: ({ context }) => { @@ -437,7 +447,7 @@ export function createTurnMachine( config: context.input.request, signal: context.llmScope.signal, content: { - messages: attemptMessages(context, recovery), + messages: attemptMessages(context), usedContextTokens: estimateUsedContextTokens(entries, { systemPrompt: context.input.request.systemPrompt, tools: context.input.request.tools, @@ -572,33 +582,38 @@ export function createTurnMachine( }), ], }, - 'llm.failed.remote': [ + 'llm.failed.remote': { + actions: raise(({ context, event }) => ({ + type: 'turn.failure.triaged' as const, + cause: event, + proposal: proposeRecovery(recovery, { + error: event.error, + messages: baseMessages(context), + applied: context.appliedRecoveries, + credentials: context.input.request.credentials, + }), + })), + }, + 'turn.failure.triaged': [ { - guard: ({ context, event }) => - proposeRecovery(recovery, { - error: event.error, - messages: baseMessages(context), - applied: context.appliedRecoveries, - }) !== undefined, + guard: ({ event }) => event.proposal !== undefined, target: 'thinking', reenter: true, actions: [ - ({ context }) => { + ({ context, event }) => { context.accumulator.rollback(); + if (event.proposal?.refreshCredentials === true) { + context.input.request.credentials?.invalidate?.(); + } }, assign(({ context, event }) => { - const proposal = proposeRecovery(recovery, { - error: event.error, - messages: baseMessages(context), - applied: context.appliedRecoveries, - }); - if (proposal === undefined) return {}; + const proposal = event.proposal as LlmRecoveryProposal & LlmRecoveryRecord; return { - lastError: event.error, appliedRecoveries: [ ...context.appliedRecoveries, { strategy: proposal.strategy, action: proposal.action }, ], + recoveryMessages: proposal.messages ?? context.recoveryMessages, attempt: 1, }; }), @@ -607,14 +622,14 @@ export function createTurnMachine( params: ({ context, event }) => llmRecoveringEvent( context.appliedRecoveries.at(-1) as LlmRecoveryRecord, - event.error, + event.cause.error, ), }, ], }, { guard: ({ context, event }) => - shouldRetry(retry, context.attempt, event.error), + shouldRetry(retry, context.attempt, event.cause.error), target: 'retrying', actions: [ ({ context }) => { @@ -622,22 +637,23 @@ export function createTurnMachine( }, assign({ delayMs: ({ context, event }) => - readRetryAfterMs(event.error) ?? retryBackoffDelay(context.attempt - 1), + readRetryAfterMs(event.cause.error) ?? + retryBackoffDelay(context.attempt - 1), }), { type: 'sendToParent', params: ({ context, event }) => - llmRetryingEvent(retry, context.attempt, context.delayMs, event.error), + llmRetryingEvent(retry, context.attempt, context.delayMs, event.cause.error), }, ], }, { target: 'failed', actions: [ - 'forwardToParent', + { type: 'sendToParent', params: ({ event }) => event.cause }, assign({ outcome: 'failed' as const, - error: ({ event }) => event.error, + error: ({ event }) => event.cause.rawError ?? event.cause.error, }), ], }, @@ -785,7 +801,7 @@ export function createTurnMachine( steps: event.messages.length > 0 ? 1 : context.steps + 1, attempt: 1, appliedRecoveries: [], - lastError: undefined, + recoveryMessages: undefined, })), 'signalRemindersConsumed', ], diff --git a/packages/agent-core-v2/src/human/credentials/credentials.ts b/packages/agent-core-v2/src/human/credentials/credentials.ts new file mode 100644 index 00000000000..e0e0666a987 --- /dev/null +++ b/packages/agent-core-v2/src/human/credentials/credentials.ts @@ -0,0 +1,66 @@ +import { errorStatusCode } from '#/llm/errors'; +import type { LlmModel } from '#/llm/model'; +import type { LlmRecovery } from '#/llm/requester/recovery'; +import { + mergeRequestHeaders, + type LlmCredential, + type LlmCredentialProvider, +} from '#/llm/requester/requester'; + +export interface CredentialTokenSource { + (options?: { readonly force?: boolean }): Promise; +} + +export function staticCredentials(apiKey?: string): LlmCredentialProvider { + return { + resolve: () => + apiKey === undefined || apiKey.trim().length === 0 ? undefined : { apiKey }, + }; +} + +export function oauthCredentials(getToken: CredentialTokenSource): LlmCredentialProvider { + let refreshed: Promise | undefined; + return { + resolve: async () => { + const pending = refreshed; + refreshed = undefined; + const apiKey = pending === undefined ? await getToken() : await pending; + return apiKey === undefined ? undefined : { apiKey }; + }, + canRecover: (error) => errorStatusCode(error) === 401, + invalidate: () => { + refreshed ??= getToken({ force: true }); + refreshed.catch(() => {}); + }, + }; +} + +export function applyCredential( + model: LlmModel, + credential: LlmCredential | undefined, +): LlmModel { + if (credential === undefined) { + return model; + } + return { + ...model, + apiKey: credential.apiKey ?? model.apiKey, + defaultHeaders: mergeRequestHeaders(model.defaultHeaders, credential.headers), + }; +} + +export async function resolveModelCredentials( + model: LlmModel, + credentials: LlmCredentialProvider | undefined, +): Promise { + return applyCredential(model, await credentials?.resolve()); +} + +export const credentialsRecovery: LlmRecovery = { + id: 'credentials', + propose: ({ error, applied, credentials }) => + credentials?.canRecover?.(error) === true && + !applied.some((record) => record.strategy === 'credentials') + ? { action: 'refresh', refreshCredentials: true } + : undefined, +}; diff --git a/packages/agent-core-v2/src/human/credentials/index.ts b/packages/agent-core-v2/src/human/credentials/index.ts new file mode 100644 index 00000000000..374911ccabd --- /dev/null +++ b/packages/agent-core-v2/src/human/credentials/index.ts @@ -0,0 +1,2 @@ +export * from './credentials'; +export * from './kimi-oauth'; diff --git a/packages/agent-core-v2/src/human/credentials/kimi-oauth.ts b/packages/agent-core-v2/src/human/credentials/kimi-oauth.ts new file mode 100644 index 00000000000..091e5b804d5 --- /dev/null +++ b/packages/agent-core-v2/src/human/credentials/kimi-oauth.ts @@ -0,0 +1,8 @@ +import type { BearerTokenProvider } from '@moonshot-ai/kimi-code-oauth'; + +import { oauthCredentials } from '#/credentials/credentials'; +import type { LlmCredentialProvider } from '#/llm/requester/requester'; + +export function kimiOAuthCredentialProvider(tokens: BearerTokenProvider): LlmCredentialProvider { + return oauthCredentials((options) => tokens.getAccessToken(options)); +} diff --git a/packages/agent-core-v2/src/human/eventStore/actor.ts b/packages/agent-core-v2/src/human/eventStore/actor.ts new file mode 100644 index 00000000000..7aeaf2e93c9 --- /dev/null +++ b/packages/agent-core-v2/src/human/eventStore/actor.ts @@ -0,0 +1,49 @@ +import { fromCallback } from '#/xstate2'; + +import type { CombinedState, EventStore, SliceMap } from './eventStore'; +import type { ExternalEvent } from './events'; +import type { StoreJournal } from './journal'; + +export type StoreActorEvent = + | { type: 'store.append'; event: ExternalEvent | readonly ExternalEvent[] } + | { type: 'store.switch'; journal: StoreJournal }; + +export type StoreActorEmitted = + | { type: 'store.ready'; state: CombinedState; branch: string } + | { type: 'store.changed'; state: CombinedState } + | { type: 'store.reset'; state: CombinedState; branch: string } + | { type: 'store.error'; error: unknown }; + +export const storeActor = fromCallback< + StoreActorEvent, + { store: EventStore }, + StoreActorEmitted +>(({ input, emit, sendBack, receive }) => { + const store = input.store; + const publish = (event: StoreActorEmitted): void => { + sendBack(event); + emit(event); + }; + publish({ type: 'store.ready', state: store.getState(), branch: store.ref.branch }); + const unsubscribe = store.subscribe((state, cause) => { + if (cause.kind === 'reset') { + publish({ type: 'store.reset', state, branch: store.ref.branch }); + } else { + publish({ type: 'store.changed', state }); + } + }); + receive((event) => { + if (event.type === 'store.append') { + void store.dispatch(event.event).catch((error: unknown) => { + publish({ type: 'store.error', error }); + }); + } else if (event.type === 'store.switch') { + void store.reset(event.journal).catch((error: unknown) => { + publish({ type: 'store.error', error }); + }); + } + }); + return unsubscribe; +}); + +export type StoreActorLogic = typeof storeActor; diff --git a/packages/agent-core-v2/src/human/eventStore/eventStore.ts b/packages/agent-core-v2/src/human/eventStore/eventStore.ts new file mode 100644 index 00000000000..62e402dc73e --- /dev/null +++ b/packages/agent-core-v2/src/human/eventStore/eventStore.ts @@ -0,0 +1,314 @@ +import { produce } from 'immer'; + +import type { BranchRef, EntryLine } from '#/store/types'; +import { StoreError } from '#/store/types'; + +import type { ExternalEvent, InternalEvent } from './events'; +import { eventSchemaFor, parseEvent, validateEvent } from './events'; +import type { JournalRecord, StoreJournal, SyncStoreJournal } from './journal'; +import type { FoldContext, Slice } from './slice'; + +export type SliceMap = Record>; + +export type CombinedState = { + readonly [K in keyof SM]: SM[K] extends Slice ? S : never; +}; + +export type Cause = + | { kind: 'event'; event: ExternalEvent; entry: EntryLine } + | { kind: 'internal'; event: InternalEvent } + | { kind: 'reset'; state: CombinedState } + | { kind: 'slice-joined'; name: string }; + +export interface EventStoreOptions { + journal: StoreJournal; + slices: SM; + drainLimit?: number; + onError?: (error: unknown) => void; +} + +export interface EventStore { + readonly ref: { tree: string; branch: string }; + readonly phase: 'open' | 'closed'; + + getState(): CombinedState; + slice(name: K): CombinedState[K]; + select(selector: (state: CombinedState) => T): T; + subscribe(listener: (state: CombinedState, cause: Cause) => void): () => void; + + dispatch(event: E | readonly E[]): Promise; + registerSlice(slice: Slice): Promise<() => void>; + reset(journal: StoreJournal): Promise; + flush(): Promise; + close(): Promise; +} + +const EVENT_ENTRY_KIND = 'event'; +const DEFAULT_DRAIN_LIMIT = 100; + +type Listener = (state: CombinedState, cause: Cause) => void; + +export async function createEventStore( + opts: EventStoreOptions, +): Promise> { + const store = new EventStoreImpl(opts); + await store.refold(opts.journal); + return store; +} + +export function createEventStoreSync( + opts: EventStoreOptions & { journal: SyncStoreJournal }, +): EventStore { + const store = new EventStoreImpl(opts); + store.refoldSync(opts.journal); + return store; +} + +class EventStoreImpl implements EventStore { + private journal: StoreJournal; + private slices: SliceMap; + private state: Record; + private phaseValue: 'open' | 'closed' = 'open'; + private tail: Promise = Promise.resolve(); + private readonly drainLimit: number; + private readonly report: (error: unknown) => void; + private readonly listeners = new Set>(); + + constructor(opts: EventStoreOptions) { + this.journal = opts.journal; + this.slices = { ...opts.slices }; + this.state = {}; + this.drainLimit = opts.drainLimit ?? DEFAULT_DRAIN_LIMIT; + this.report = opts.onError ?? ((error) => console.error(error)); + } + + get ref(): { tree: string; branch: string } { + return this.journal.ref; + } + + get phase(): 'open' | 'closed' { + return this.phaseValue; + } + + getState(): CombinedState { + return this.state as CombinedState; + } + + slice(name: K): CombinedState[K] { + return this.state[name as string] as CombinedState[K]; + } + + select(selector: (state: CombinedState) => T): T { + return selector(this.getState()); + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + dispatch(event: E | readonly E[]): Promise { + if (this.phaseValue !== 'open') { + return Promise.reject(new StoreError('closed', 'store is closed')); + } + const events = (Array.isArray(event) ? event : [event]) as readonly E[]; + for (const item of events) { + const invalid = validateEvent(item); + if (invalid !== undefined) { + return Promise.reject(invalid); + } + } + const result = this.tail.then(() => this.foldAndAppend(events)); + this.tail = result.then(noop, noop); + return result; + } + + async registerSlice(slice: Slice): Promise<() => void> { + if (this.phaseValue !== 'open') { + throw new StoreError('closed', 'store is closed'); + } + if (this.slices[slice.name] !== undefined) { + throw new StoreError('duplicate-slice', `slice '${slice.name}' is already registered`); + } + const op = this.tail.then(async () => { + this.slices = { ...this.slices, [slice.name]: slice }; + await this.refold(this.journal); + }); + this.tail = op.then(noop, noop); + await op; + this.notify([{ kind: 'slice-joined', name: slice.name }]); + return () => { + const slices = { ...this.slices }; + delete slices[slice.name]; + this.slices = slices; + const state = { ...this.state }; + delete state[slice.name]; + this.state = state; + }; + } + + reset(journal: StoreJournal): Promise { + if (this.phaseValue !== 'open') { + return Promise.reject(new StoreError('closed', 'store is closed')); + } + const op = this.tail.then(async () => { + await this.journal.settled(); + this.journal = journal; + await this.refold(journal); + }); + this.tail = op.then(noop, noop); + return op.then(() => { + this.notify([{ kind: 'reset', state: this.getState() }]); + }); + } + + flush(): Promise { + return this.tail.then(() => this.journal.settled()); + } + + async close(): Promise { + await this.flush(); + this.phaseValue = 'closed'; + this.listeners.clear(); + } + + async refold(journal: StoreJournal): Promise { + const records: JournalRecord[] = []; + for await (const record of journal.read()) { + records.push(record); + } + this.foldRecords(records); + } + + refoldSync(journal: SyncStoreJournal): void { + this.foldRecords(journal.readSync()); + } + + private foldRecords(records: JournalRecord[]): void { + const seeded: Record = {}; + for (const [name, slice] of Object.entries(this.slices)) { + seeded[name] = slice.initialState(); + } + this.state = seeded; + for (const record of records) { + if (record.kind !== EVENT_ENTRY_KIND) continue; + this.replayRecord(record); + } + } + + private replayRecord(record: JournalRecord): void { + if (eventSchemaFor(record.type) === undefined) return; + const event = parseEvent(record.type, record.data); + if (event === undefined) { + this.report( + new StoreError('schema', `event '${record.type}' at seq ${record.seq} failed schema validation`), + ); + return; + } + const ref: BranchRef = { branch: record.branch, seq: record.seq }; + const { raised } = this.applyEvent(event, ref, record.ts, true); + this.drain(raised, ref, record.ts, true); + } + + private async foldAndAppend(events: readonly ExternalEvent[]): Promise { + const causes: Cause[] = []; + const entries: EntryLine[] = []; + for (const event of events) { + const ts = typeof event.time === 'number' ? event.time : Date.now(); + const ref: BranchRef = { branch: this.journal.ref.branch, seq: this.journal.nextSeq() }; + const { raised, effects } = this.applyEvent(event, ref, ts, false); + const internalCauses = this.drain(raised, ref, ts, false); + for (const effect of effects) { + try { + effect(); + } catch (error) { + this.report(error); + } + } + const entry = await this.journal.append({ type: event.type, kind: EVENT_ENTRY_KIND, data: event }); + entries.push(entry); + causes.push({ kind: 'event', event, entry }, ...internalCauses); + } + this.notify(causes); + return entries[entries.length - 1] as EntryLine; + } + + private applyEvent( + event: { type: string }, + ref: BranchRef, + ts: number, + replaying: boolean, + ): { raised: InternalEvent[]; effects: (() => void)[] } { + const raised: InternalEvent[] = []; + const effects: (() => void)[] = []; + const ctx: FoldContext = { + ref, + ts, + replaying, + enqueue: { + raise: (internal) => { + raised.push(internal); + }, + effect: (fn) => { + effects.push(fn); + }, + }, + }; + let changed = false; + const next: Record = { ...this.state }; + for (const [name, slice] of Object.entries(this.slices)) { + const reducer = slice.reducers[event.type]; + if (reducer === undefined) continue; + changed = true; + next[name] = produce(next[name], (draft) => reducer(draft, event, ctx)); + } + if (changed) { + this.state = next; + } + return { raised, effects }; + } + + private drain( + initial: InternalEvent[], + ref: BranchRef, + ts: number, + replaying: boolean, + ): Cause[] { + const causes: Cause[] = []; + const queue = [...initial]; + let count = 0; + while (queue.length > 0) { + count += 1; + if (count > this.drainLimit) { + throw new StoreError('drain-limit', `internal event drain exceeded limit ${this.drainLimit}`); + } + const internal = queue.shift() as InternalEvent; + const { raised, effects } = this.applyEvent(internal, ref, ts, replaying); + queue.push(...raised); + if (!replaying) { + for (const effect of effects) { + try { + effect(); + } catch (error) { + this.report(error); + } + } + causes.push({ kind: 'internal', event: internal }); + } + } + return causes; + } + + private notify(causes: Cause[]): void { + const state = this.getState(); + for (const cause of causes) { + for (const listener of this.listeners) { + listener(state, cause); + } + } + } +} + +function noop(): void {} diff --git a/packages/agent-core-v2/src/human/eventStore/events.ts b/packages/agent-core-v2/src/human/eventStore/events.ts new file mode 100644 index 00000000000..0f82bda31f8 --- /dev/null +++ b/packages/agent-core-v2/src/human/eventStore/events.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +import { StoreError } from '#/store/types'; + +export type ExternalEvent

> = P & { type: string; time: number }; + +export interface EventFactory

{ + (payload: P): ExternalEvent

; + readonly type: string; + readonly schema: z.ZodTypeAny; +} + +export type EventOf = F extends EventFactory ? ExternalEvent

: never; + +export type InternalEvent = { type: string } & Record; + +const registry = new Map(); + +export function defineEvent

(def: { type: string; schema: z.ZodType

}): EventFactory

{ + if (registry.has(def.type)) { + throw new StoreError('duplicate-event', `duplicate event type '${def.type}'`); + } + registry.set(def.type, def.schema); + const factory = (payload: P): ExternalEvent

=> ({ + ...payload, + type: def.type, + time: Date.now(), + }); + factory.type = def.type; + factory.schema = def.schema; + return factory; +} + +export function eventSchemaFor(type: string): z.ZodTypeAny | undefined { + return registry.get(type); +} + +export function parseEvent( + type: string, + record: unknown, +): (ExternalEvent & Record) | undefined { + const schema = registry.get(type); + if (schema === undefined) return undefined; + if (typeof record !== 'object' || record === null) return undefined; + const { type: _type, time: _time, ...payload } = record as Record; + if (!schema.safeParse(payload).success) return undefined; + return record as ExternalEvent & Record; +} + +export function validateEvent(event: ExternalEvent): StoreError | undefined { + const schema = registry.get(event.type); + if (schema === undefined) { + return new StoreError('unregistered-event', `event '${event.type}' is not a registered external event`); + } + const { type: _type, time: _time, ...payload } = event; + const parsed = schema.safeParse(payload); + if (!parsed.success) { + return new StoreError('schema', `event '${event.type}' failed schema validation: ${parsed.error.message}`); + } + return undefined; +} diff --git a/packages/agent-core-v2/src/human/eventStore/index.ts b/packages/agent-core-v2/src/human/eventStore/index.ts new file mode 100644 index 00000000000..66ff89537b5 --- /dev/null +++ b/packages/agent-core-v2/src/human/eventStore/index.ts @@ -0,0 +1,5 @@ +export * from './events'; +export * from './slice'; +export * from './journal'; +export * from './eventStore'; +export * from './actor'; diff --git a/packages/agent-core-v2/src/human/eventStore/journal.ts b/packages/agent-core-v2/src/human/eventStore/journal.ts new file mode 100644 index 00000000000..1bee1792124 --- /dev/null +++ b/packages/agent-core-v2/src/human/eventStore/journal.ts @@ -0,0 +1,104 @@ +import type { Branch } from '#/store/branch'; +import type { Tree } from '#/store/tree'; +import type { AppendInput, EntryLine } from '#/store/types'; +import { isOffloadedPayload } from '#/store/types'; + +export interface JournalRecord { + branch: string; + seq: number; + ts: number; + type: string; + kind: string; + data: unknown; +} + +export interface StoreJournal { + readonly ref: { tree: string; branch: string }; + append(input: AppendInput): Promise; + read(): AsyncIterable; + nextSeq(): number; + settled(): Promise; +} + +export interface SyncStoreJournal extends StoreJournal { + readSync(): JournalRecord[]; +} + +export function memoryJournal(ref?: { tree: string; branch: string }): SyncStoreJournal { + const journalRef = ref ?? { tree: 'memory', branch: 'main' }; + const entries: EntryLine[] = []; + const records = (): JournalRecord[] => + entries.map((entry) => ({ + branch: journalRef.branch, + seq: entry.seq, + ts: entry.ts, + type: entry.type, + kind: entry.payload.kind, + data: isOffloadedPayload(entry.payload) ? null : entry.payload.data, + })); + return { + ref: journalRef, + append: (input) => { + const data = input.data ?? null; + const entry: EntryLine = { + kind: 'entry', + seq: entries.length, + ts: Date.now(), + type: input.type, + payload: { kind: input.kind, size: JSON.stringify(data).length, data }, + }; + entries.push(entry); + return Promise.resolve(entry); + }, + read: async function* () { + for (const record of records()) yield record; + }, + readSync: records, + nextSeq: () => entries.length, + settled: () => Promise.resolve(), + }; +} + +export function journalFromBranch(branch: Branch, tree: Tree): StoreJournal { + return { + ref: { tree: branch.tree, branch: branch.name }, + append: (input) => branch.append(input), + settled: () => branch.settled(), + read: () => readBranchChain(branch, tree), + nextSeq: () => branch.nextSeq, + }; +} + +async function* readBranchChain(branch: Branch, tree: Tree): AsyncIterable { + const chain: { name: string; entries: EntryLine[] }[] = []; + let current: Branch | undefined = branch; + let upto: number | null = null; + while (current !== undefined) { + const head = upto ?? current.head; + const entries: EntryLine[] = []; + for (let seq = 0; seq <= (head ?? -1); seq++) { + const entry = current.entryAt(seq); + if (entry !== null) entries.push(entry); + } + chain.push({ name: current.name, entries }); + const parentBranch: string | undefined = current.header.parentBranch; + const parentSeq: number | undefined = current.header.parentSeq; + current = + parentBranch !== undefined && parentSeq !== undefined && tree.has(parentBranch) + ? tree.openBranch(parentBranch) + : undefined; + upto = parentSeq ?? null; + } + for (const segment of chain.reverse()) { + for (const entry of segment.entries) { + yield { + branch: segment.name, + seq: entry.seq, + ts: entry.ts, + type: entry.type, + kind: entry.payload.kind, + data: await tree.resolve(entry), + }; + } + } +} diff --git a/packages/agent-core-v2/src/human/eventStore/slice.ts b/packages/agent-core-v2/src/human/eventStore/slice.ts new file mode 100644 index 00000000000..3479db4d71a --- /dev/null +++ b/packages/agent-core-v2/src/human/eventStore/slice.ts @@ -0,0 +1,25 @@ +import type { BranchRef } from '#/store/types'; + +import type { InternalEvent } from './events'; + +export interface Enqueue { + raise(event: InternalEvent): void; + effect(fn: () => void): void; +} + +export interface FoldContext { + readonly ref: BranchRef; + readonly ts: number; + readonly replaying: boolean; + readonly enqueue: Enqueue; +} + +export interface Slice { + readonly name: Name; + readonly initialState: () => S; + readonly reducers: Record void | S>; +} + +export function createSlice(def: Slice): Slice { + return def; +} diff --git a/packages/agent-core-v2/src/human/index.ts b/packages/agent-core-v2/src/human/index.ts index 5f908ec4fe3..508b2bc2d01 100644 --- a/packages/agent-core-v2/src/human/index.ts +++ b/packages/agent-core-v2/src/human/index.ts @@ -11,31 +11,35 @@ export * from './llm/usage'; export * from './plugin'; export * from './llm/protocol/format'; export * from './llm/protocol/base'; -export * from './llm/protocol/trait'; +export * from './llm/protocol/connection'; +export * from './llm/protocol/thinking'; export * from './llm/protocol/rewrite'; export * from './llm/protocol/patterns'; export * from './llm/media'; export * from './llm/requester/requester'; export * from './llm/empty-response'; -export * from './llm/requester/machine'; +export * from './llm/requester/actor'; export * from './llm/requester/recovery'; export * from './llm/requester/retry'; +export * from './llm/requester/bases/openai/contract'; export * from './llm/requester/bases/openai/capability'; +export * from './llm/requester/bases/openai/trait'; export * from './llm/requester/bases/openai/extra-params'; -export * from './llm/requester/bases/openai/format'; -export * from './llm/requester/bases/openai/reasoning-key'; export * from './llm/requester/bases/openai/requester'; +export * from './llm/requester/bases/openai-responses/contract'; export * from './llm/requester/bases/openai-responses/capability'; +export * from './llm/requester/bases/openai-responses/trait'; export * from './llm/requester/bases/openai-responses/extra-params'; -export * from './llm/requester/bases/openai-responses/format'; export * from './llm/requester/bases/openai-responses/requester'; +export * from './llm/requester/bases/google-genai/contract'; export * from './llm/requester/bases/google-genai/capability'; +export * from './llm/requester/bases/google-genai/trait'; export * from './llm/requester/bases/google-genai/extra-params'; -export * from './llm/requester/bases/google-genai/format'; export * from './llm/requester/bases/google-genai/requester'; +export * from './llm/requester/bases/anthropic/contract'; export * from './llm/requester/bases/anthropic/capability'; +export * from './llm/requester/bases/anthropic/trait'; export * from './llm/requester/bases/anthropic/extra-params'; -export * from './llm/requester/bases/anthropic/format'; export * from './llm/requester/bases/anthropic/profile'; export * from './llm/requester/bases/anthropic/requester'; export * from './llm/requester/bases/tool-call-id'; @@ -49,7 +53,7 @@ export * from './llm-kimi/media'; export * from './llm-kimi/schema'; export * from './llm-kimi/trait'; export * from './llm/provider/providers/standard'; -export * from './kimi-oauth'; +export * from './credentials'; export * from './tool/executor'; export * from './tool/machine'; export * from './tool/wait-for'; @@ -60,13 +64,16 @@ export * from './agent/machine'; export * from './agent/wait-for'; export * from './agent/turn'; export * from './agent/context-usage'; -export * from './agent/replay'; -export * from './persist/agent'; +export * from './agent/events'; +export * from './agent/slices'; +export * from './agent/historySchema'; export * from './persist/open'; +export * from './eventStore/index'; export * from './persist/v2/migrate'; export * from './session/machine'; -export * from './session/undo'; -export * from './persist/session'; +export * from './session/events'; +export * from './session/slices'; +export * from './session/stores'; export * from './usage/usage'; export * from './usage/machine'; export * from './usage/plugin'; @@ -76,7 +83,7 @@ export * from './interaction/machine'; export * from './interaction/facade'; export * from './kimi/trace'; export * from './todo/todoItem'; -export * from './todo/state'; +export * from './todo/slice'; export * from './todo/tool'; export * from './todo/plugin'; export * from './tool-select/state'; diff --git a/packages/agent-core-v2/src/human/kimi-oauth/credential-source.ts b/packages/agent-core-v2/src/human/kimi-oauth/credential-source.ts deleted file mode 100644 index 28a148c9b1c..00000000000 --- a/packages/agent-core-v2/src/human/kimi-oauth/credential-source.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { MediaVideoUploader } from '#/llm/media/upload'; -import type { LlmRemoteErrorMessage } from '#/llm/errors'; -import type { LlmModel } from '#/llm/model'; -import type { - LlmRequestConfig, - LlmRequestContent, - LlmRequestControl, - LlmRequester, -} from '#/llm/requester/requester'; - -export interface CredentialResolveOptions { - readonly force?: boolean; -} - -export interface CredentialSource { - resolve(model: LlmModel, options?: CredentialResolveOptions): Promise | LlmModel; - canRecover?(model: LlmModel, error: unknown): boolean; -} - -async function runWithCredentials( - source: CredentialSource, - model: LlmModel, - aborted: () => boolean, - run: (model: LlmModel) => Promise, -): Promise { - const resolved = await source.resolve(model); - try { - return await run(resolved); - } catch (error) { - if (aborted() || source.canRecover?.(resolved, error) !== true) { - throw error; - } - } - const refreshed = await source.resolve(model, { force: true }); - return run(refreshed); -} - -export function withAuth(inner: LlmRequester, source: CredentialSource): LlmRequester { - return { - async generate( - config: LlmRequestConfig, - content: LlmRequestContent, - control: LlmRequestControl, - ): Promise { - const resolved = await source.resolve(config.model); - let failed: LlmRemoteErrorMessage | undefined; - await inner.generate({ ...config, model: resolved }, content, { - ...control, - onEvent: (event) => { - if (event.type === 'llm.failed.remote') { - failed = event.error; - return; - } - control.onEvent?.(event); - }, - }); - if (failed === undefined) { - return; - } - const failure: LlmRemoteErrorMessage = failed; - if (control.signal.aborted || source.canRecover?.(resolved, failure) !== true) { - control.onEvent?.({ type: 'llm.failed.remote', error: failure }); - return; - } - const refreshed = await source.resolve(config.model, { force: true }); - await inner.generate({ ...config, model: refreshed }, content, control); - }, - }; -} - -export function withAuthUpload( - inner: MediaVideoUploader, - source: CredentialSource, -): MediaVideoUploader { - return (video, options) => - runWithCredentials(source, options.model, () => options.signal?.aborted === true, (model) => - inner(video, { ...options, model }), - ); -} diff --git a/packages/agent-core-v2/src/human/kimi-oauth/index.ts b/packages/agent-core-v2/src/human/kimi-oauth/index.ts deleted file mode 100644 index e22369501ad..00000000000 --- a/packages/agent-core-v2/src/human/kimi-oauth/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './credential-source'; -export * from './kimi-oauth'; diff --git a/packages/agent-core-v2/src/human/kimi-oauth/kimi-oauth.ts b/packages/agent-core-v2/src/human/kimi-oauth/kimi-oauth.ts deleted file mode 100644 index 4690301462b..00000000000 --- a/packages/agent-core-v2/src/human/kimi-oauth/kimi-oauth.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { BearerTokenProvider } from '@moonshot-ai/kimi-code-oauth'; - -import type { CredentialSource } from './credential-source'; - -function statusOf(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) { - return undefined; - } - const record = error as Record; - const status = record['status'] ?? record['statusCode']; - return typeof status === 'number' ? status : undefined; -} - -export function kimiOAuthCredentialSource(tokens: BearerTokenProvider): CredentialSource { - return { - resolve: async (model, options) => ({ - ...model, - apiKey: await tokens.getAccessToken({ force: options?.force === true }), - }), - canRecover: (_model, error) => statusOf(error) === 401, - }; -} diff --git a/packages/agent-core-v2/src/human/llm-kimi/provider.ts b/packages/agent-core-v2/src/human/llm-kimi/provider.ts index 10f8c9307eb..b24f8c5aa2d 100644 --- a/packages/agent-core-v2/src/human/llm-kimi/provider.ts +++ b/packages/agent-core-v2/src/human/llm-kimi/provider.ts @@ -3,15 +3,30 @@ import { anthropicBetaBase } from '#/llm/requester/bases/anthropic/requester'; import { openAIBase } from '#/llm/requester/bases/openai/requester'; import { openAIResponsesBase } from '#/llm/requester/bases/openai-responses/requester'; +import { kimiAnthropicTrait, kimiConnection, kimiOpenAITrait } from './trait'; +import { classifyKimiQuotaError } from './errors'; import { kimiMediaContribution } from './media'; -import { kimiAnthropicTrait, kimiOpenAITrait, kimiResponsesTrait } from './trait'; export const kimiProvider = createProvider({ id: 'kimi', protocols: { - openai: { base: openAIBase, trait: kimiOpenAITrait }, - anthropic: { base: anthropicBetaBase, trait: kimiAnthropicTrait }, - openai_responses: { base: openAIResponsesBase, trait: kimiResponsesTrait }, + openai: { + base: openAIBase, + trait: kimiOpenAITrait, + connection: kimiConnection, + convertError: classifyKimiQuotaError, + }, + anthropic: { + base: anthropicBetaBase, + trait: kimiAnthropicTrait, + connection: kimiConnection, + convertError: classifyKimiQuotaError, + }, + openai_responses: { + base: openAIResponsesBase, + connection: kimiConnection, + convertError: classifyKimiQuotaError, + }, }, media: kimiMediaContribution, }); diff --git a/packages/agent-core-v2/src/human/llm-kimi/trait.ts b/packages/agent-core-v2/src/human/llm-kimi/trait.ts index 794586ec5bb..58eb81482da 100644 --- a/packages/agent-core-v2/src/human/llm-kimi/trait.ts +++ b/packages/agent-core-v2/src/human/llm-kimi/trait.ts @@ -1,9 +1,15 @@ -import type { ProtocolEndpoint, ProtocolTrait } from '#/llm/protocol/trait'; +import type { ProtocolEndpoint, ProviderConnection } from '#/llm/protocol/connection'; import type { ContentPart, ToolDescription } from '#/llm/message'; import { providerImagePolicy } from '#/llm/media/image-formats'; -import { CONTEXT_MANAGEMENT_BETA } from '#/llm/requester/bases/anthropic/format'; +import { CONTEXT_MANAGEMENT_BETA } from '#/llm/requester/bases/anthropic/contract'; +import type { AnthropicTrait } from '#/llm/requester/bases/anthropic/trait'; +import type { + OpenAIRawUsage, + OpenAIWireMessage, + OpenAIWireToolCall, +} from '#/llm/requester/bases/openai/contract'; +import type { OpenAITrait } from '#/llm/requester/bases/openai/trait'; -import { classifyKimiQuotaError } from './errors'; import { normalizeKimiToolSchema } from './schema'; export const KIMI_API_KEY_ENV = 'KIMI_API_KEY'; @@ -16,6 +22,10 @@ const kimiEndpoint: ProtocolEndpoint = { defaultBaseUrl: KIMI_DEFAULT_BASE_URL, }; +export const kimiConnection: ProviderConnection = { + endpoint: () => kimiEndpoint, +}; + export interface KimiThinkingConfig { type?: 'enabled' | 'disabled'; effort?: string; @@ -23,11 +33,6 @@ export interface KimiThinkingConfig { [key: string]: unknown; } -export interface ExtraBody { - thinking?: KimiThinkingConfig; - [key: string]: unknown; -} - function isEffectivelyEmptyContent(parts: readonly ContentPart[]): boolean { for (const part of parts) { if (part.type !== 'text') { @@ -59,20 +64,14 @@ function convertKimiTool(tool: ToolDescription): Record { const kimiAcceptedImageMimes = (): ReadonlySet => providerImagePolicy('kimi').acceptedMimes; -export const kimiOpenAITrait: ProtocolTrait = { +export const kimiOpenAITrait: OpenAITrait = { strictThinkingValidation: true, - endpoint: () => kimiEndpoint, - - acceptedImageMimes: kimiAcceptedImageMimes, - - convertError: (error) => classifyKimiQuotaError(error), - - toolMessageConversion: () => 'keep_parts', + toolMessageConversion: 'keep_parts', cacheKey: (key) => ({ prompt_cache_key: key }), - withThinking: (thinking) => { + thinking: (thinking) => { const config: KimiThinkingConfig = thinking.effort === 'off' ? { type: 'disabled' } @@ -82,61 +81,49 @@ export const kimiOpenAITrait: ProtocolTrait = { if (thinking.keep !== undefined) { config.keep = thinking.keep; } - return { extra_body: { thinking: config } }; - }, - - preserveThinking: (thinking) => { - if (thinking.keep === 'all' && thinking.effort !== 'off') { - return true; - } - return undefined; + return { + kwargs: { thinking: config }, + preserveThinking: thinking.keep === 'all' && thinking.effort !== 'off' ? true : undefined, + }; }, - withMaxCompletionTokens: (maxCompletionTokens) => ({ + maxCompletionTokens: (maxCompletionTokens) => ({ max_completion_tokens: maxCompletionTokens, }), buildParams: (params) => { - const { - extra_body: extraBody, - max_tokens: maxTokens, - max_completion_tokens: maxCompletionTokens, - ...rest - } = params; - const out: Record = { ...rest }; - const resolvedMaxCompletionTokens = maxCompletionTokens ?? maxTokens; - if (resolvedMaxCompletionTokens !== undefined) { - out['max_completion_tokens'] = resolvedMaxCompletionTokens; - } - if (extraBody !== undefined && extraBody !== null) { - Object.assign(out, extraBody); + const { extra_body: extraBody, ...rest } = params; + if (extraBody === undefined || extraBody === null) { + return params; } - return out; + return { ...rest, ...(extraBody as Record) }; }, convertTool: (tool) => convertKimiTool(tool), convertMessage: (message, converted) => { + const record = converted as Partial & Record; if (message.role === 'assistant' && message.toolCalls.length > 0) { const nonThinkParts = message.content.filter((part) => part.type !== 'think'); if (isEffectivelyEmptyContent(nonThinkParts)) { - delete converted['content']; + delete record['content']; } } if (message.role === 'system' && message.tools !== undefined && message.tools.length > 0) { - converted['tools'] = message.tools.map((tool) => convertKimiTool(tool)); + record['tools'] = message.tools.map((tool) => convertKimiTool(tool)); } - const convertedToolCalls = converted['tool_calls']; + const convertedToolCalls = record['tool_calls']; if (message.role === 'assistant' && Array.isArray(convertedToolCalls)) { message.toolCalls.forEach((toolCall, index) => { if (toolCall.extras === undefined) { return; } - const out = convertedToolCalls[index] as Record | undefined; + const out: (OpenAIWireToolCall & { extras?: unknown }) | undefined = + convertedToolCalls[index]; if (out !== undefined) { - out['extras'] = toolCall.extras; + out.extras = toolCall.extras; } }); } @@ -145,46 +132,32 @@ export const kimiOpenAITrait: ProtocolTrait = { }, extractUsage: (chunk) => { - const topLevel = chunk['usage']; + const topLevel = chunk.usage; if (topLevel !== null && topLevel !== undefined && typeof topLevel === 'object') { - return topLevel as Record; + return topLevel; } - const choices = chunk['choices']; - if (!Array.isArray(choices) || choices.length === 0) { - return undefined; - } - const firstChoice = choices[0] as Record | undefined; - const choiceUsage = firstChoice?.['usage']; + const firstChoice = chunk.choices?.[0] as { usage?: OpenAIRawUsage | null } | undefined; + const choiceUsage = firstChoice?.usage; if (choiceUsage !== null && choiceUsage !== undefined && typeof choiceUsage === 'object') { - return choiceUsage as Record; + return choiceUsage; } return undefined; }, }; -export const kimiAnthropicTrait: ProtocolTrait = { - endpoint: () => kimiEndpoint, - +export const kimiAnthropicTrait: AnthropicTrait = { acceptedImageMimes: kimiAcceptedImageMimes, - convertError: (error) => classifyKimiQuotaError(error), - - withThinking: (thinking) => { + thinking: (thinking) => { if (thinking.effort === 'off') { - return { thinking: { type: 'disabled' }, betaFeatures: [CONTEXT_MANAGEMENT_BETA] }; + return { kwargs: { thinking: { type: 'disabled' }, betaFeatures: [CONTEXT_MANAGEMENT_BETA] } }; } return { - thinking: { type: 'enabled' }, - output_config: thinking.effort === 'on' ? undefined : { effort: thinking.effort }, - betaFeatures: [CONTEXT_MANAGEMENT_BETA], + kwargs: { + thinking: { type: 'enabled' }, + output_config: thinking.effort === 'on' ? undefined : { effort: thinking.effort }, + betaFeatures: [CONTEXT_MANAGEMENT_BETA], + }, }; }, }; - -export const kimiResponsesTrait: ProtocolTrait = { - endpoint: () => kimiEndpoint, - - acceptedImageMimes: kimiAcceptedImageMimes, - - convertError: (error) => classifyKimiQuotaError(error), -}; diff --git a/packages/agent-core-v2/src/human/llm/errors.ts b/packages/agent-core-v2/src/human/llm/errors.ts index 9f99b67c0b1..ebb25e9f772 100644 --- a/packages/agent-core-v2/src/human/llm/errors.ts +++ b/packages/agent-core-v2/src/human/llm/errors.ts @@ -17,6 +17,15 @@ export function isAbortError(error: unknown): boolean { ); } +export function errorStatusCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + const record = error as Record; + const status = record['status'] ?? record['statusCode']; + return typeof status === 'number' ? status : undefined; +} + export type LlmErrorKind = | 'syntax' | 'abort' diff --git a/packages/agent-core-v2/src/human/llm/media/resolver.ts b/packages/agent-core-v2/src/human/llm/media/resolver.ts index 73974c1c61e..a3364e207d5 100644 --- a/packages/agent-core-v2/src/human/llm/media/resolver.ts +++ b/packages/agent-core-v2/src/human/llm/media/resolver.ts @@ -1,7 +1,7 @@ import type { ModelCapability } from '#/llm/capability'; import type { ContentPart, Message, VideoURLPart } from '#/llm/message'; import type { Provider } from '#/llm/provider/definition'; -import type { MessageResolveContext, MessageResolver } from '#/llm/requester/machine'; +import type { MessageResolveContext, MessageResolver } from '#/llm/requester/actor'; import type { MediaUploadCache } from './cache'; import { mediaKindForMime, mediaMimeForPath, type MediaKind } from './mime'; diff --git a/packages/agent-core-v2/src/human/llm/protocol/base.ts b/packages/agent-core-v2/src/human/llm/protocol/base.ts index 42a3f21bd30..188881f45ec 100644 --- a/packages/agent-core-v2/src/human/llm/protocol/base.ts +++ b/packages/agent-core-v2/src/human/llm/protocol/base.ts @@ -1,11 +1,22 @@ import type { ModelCapability } from '#/llm/capability'; -import type { LlmRequester } from '#/llm/requester/requester'; +import type { LlmModel } from '#/llm/model'; +import type { LlmErrorClassifier, LlmRequester } from '#/llm/requester/requester'; -import type { ProtocolTrait } from './trait'; +import type { ProviderConnection } from './connection'; export type ProtocolName = 'openai' | 'openai_responses' | 'anthropic' | 'google-genai'; -export interface ProtocolBase { +export interface TraitContext { + readonly model: LlmModel; +} + +export interface ProtocolRequesterOptions { + readonly connection?: ProviderConnection; + readonly trait?: TTrait; + readonly convertError?: LlmErrorClassifier; +} + +export interface ProtocolBase { capability?(modelName: string): ModelCapability | undefined; - createRequester(trait?: ProtocolTrait): LlmRequester; + createRequester(options?: ProtocolRequesterOptions): LlmRequester; } diff --git a/packages/agent-core-v2/src/human/llm/protocol/connection.ts b/packages/agent-core-v2/src/human/llm/protocol/connection.ts new file mode 100644 index 00000000000..8e49c113590 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/protocol/connection.ts @@ -0,0 +1,39 @@ +import type { LlmModel } from '#/llm/model'; + +export interface ConnectionContext { + readonly model: LlmModel; +} + +export interface ProtocolEndpoint { + readonly apiKeyEnv?: string; + readonly baseUrlEnv?: string; + readonly defaultBaseUrl?: string; +} + +export interface ProviderConnection { + endpoint?(ctx?: ConnectionContext): ProtocolEndpoint | undefined; + + defaultHeaders?(ctx: ConnectionContext): Record | undefined; +} + +export function resolveModelConnection( + model: LlmModel, + connection: ProviderConnection | undefined, +): LlmModel { + const declaration = connection?.endpoint?.({ model }); + if (declaration === undefined) { + return model; + } + const read = (envName: string | undefined): string | undefined => { + if (envName === undefined) { + return undefined; + } + const value = process.env[envName]; + return value !== undefined && value.length > 0 ? value : undefined; + }; + return { + ...model, + baseUrl: model.baseUrl ?? read(declaration.baseUrlEnv) ?? declaration.defaultBaseUrl, + apiKey: model.apiKey ?? read(declaration.apiKeyEnv), + }; +} diff --git a/packages/agent-core-v2/src/human/llm/protocol/format.ts b/packages/agent-core-v2/src/human/llm/protocol/format.ts index 26f6bdfac0d..c25ed5e175b 100644 --- a/packages/agent-core-v2/src/human/llm/protocol/format.ts +++ b/packages/agent-core-v2/src/human/llm/protocol/format.ts @@ -1,6 +1,5 @@ import type { LlmRemoteErrorMessage } from '#/llm/errors'; import type { FinishInfo } from '#/llm/finish-reason'; -import type { ProtocolTrait, TraitContext } from '#/llm/protocol/trait'; import type { Message, StreamedMessagePart, ToolDescription } from '#/llm/message'; import type { LlmRequestConfig } from '#/llm/requester/requester'; import type { TokenUsage } from '#/llm/usage'; @@ -9,12 +8,22 @@ export type FormatRequestInput = LlmRequestConfig & { readonly messages: readonly Message[]; readonly tools: readonly ToolDescription[]; readonly usedContextTokens?: number; - readonly trait: ProtocolTrait | undefined; - readonly ctx: TraitContext; }; -export interface FormatRequestOptions { - readonly reasoningKey?: string; +export function resolveMaxCompletionCap(input: FormatRequestInput): number | undefined { + const { maxCompletionTokens, usedContextTokens, maxContextTokens } = input; + if (maxCompletionTokens === undefined) { + return undefined; + } + let cap = maxCompletionTokens; + if ( + usedContextTokens !== undefined && + maxContextTokens !== undefined && + maxContextTokens > 0 + ) { + cap = Math.min(cap, maxContextTokens - usedContextTokens); + } + return Math.max(1, cap); } export interface StreamParseSink { @@ -25,18 +34,18 @@ export interface StreamParseSink { onError?(message: LlmRemoteErrorMessage): void; } -export type StreamParser = (chunk: TChunk, sink: StreamParseSink) => void; - -export interface StreamParserOptions { - readonly trait?: ProtocolTrait; - readonly ctx: TraitContext; +export interface StreamParserOptions { + resolveUsage?( + chunk: TChunk, + defaultUsage: Partial | undefined, + ): Partial | undefined; } -export interface ProtocolFormat< - TRequest = Record, - _TResponse = unknown, - TChunk = unknown, -> { - formatRequest(input: FormatRequestInput, options?: FormatRequestOptions): TRequest; - createStreamParser(options?: StreamParserOptions): StreamParser; +export type StreamParser = ( + chunk: TChunk, + sink: StreamParseSink, +) => void; + +export interface ProtocolFormat { + createStreamParser(options?: StreamParserOptions): StreamParser; } diff --git a/packages/agent-core-v2/src/human/llm/protocol/thinking.ts b/packages/agent-core-v2/src/human/llm/protocol/thinking.ts new file mode 100644 index 00000000000..d5e9f3054a9 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/protocol/thinking.ts @@ -0,0 +1,38 @@ +import type { ThinkingRequestOptions } from '#/llm/thinking'; + +import type { TraitContext } from './base'; + +export interface ThinkingApplication { + readonly kwargs: Record; + readonly preserveThinking?: boolean; +} + +export type ThinkingStrategy = ( + thinking: ThinkingRequestOptions, + ctx: TraitContext, +) => ThinkingApplication | undefined; + +export type ThinkingFallback = ( + thinking: ThinkingRequestOptions, + ctx: TraitContext, +) => Record | undefined; + +export interface ResolvedThinking { + readonly kwargs: Record; + readonly preserveThinking: boolean; +} + +export function applyThinking( + kwargs: Record, + thinking: ThinkingRequestOptions, + strategy: ThinkingStrategy | undefined, + ctx: TraitContext, + fallback?: ThinkingFallback, +): ResolvedThinking { + const applied = strategy?.(thinking, ctx); + const hookedKwargs = applied === undefined ? fallback?.(thinking, ctx) : applied.kwargs; + return { + kwargs: hookedKwargs === undefined ? kwargs : { ...kwargs, ...hookedKwargs }, + preserveThinking: applied?.preserveThinking ?? false, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/protocol/trait.ts b/packages/agent-core-v2/src/human/llm/protocol/trait.ts deleted file mode 100644 index 2afb8231e37..00000000000 --- a/packages/agent-core-v2/src/human/llm/protocol/trait.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { ModelCapability } from '#/llm/capability'; -import type { LlmRemoteErrorMessage } from '#/llm/errors'; -import type { Message, ToolDescription } from '#/llm/message'; -import type { LlmModel } from '#/llm/model'; -import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester'; -import type { ThinkingRequestOptions } from '#/llm/thinking'; - -export interface TraitContext { - readonly model: LlmModel; -} - -export interface ProtocolEndpoint { - readonly apiKeyEnv?: string; - readonly baseUrlEnv?: string; - readonly defaultBaseUrl?: string; -} - -export interface ProtocolTrait { - readonly strictThinkingValidation?: boolean; - - endpoint?(ctx?: TraitContext): ProtocolEndpoint | undefined; - - defaultHeaders?(ctx: TraitContext): Record | undefined; - - convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; - - convertMessage?( - message: Message, - converted: Record, - ctx: TraitContext, - ): Record | null; - - mergeHistory?( - messages: readonly Record[], - ctx: TraitContext, - ): Record[] | undefined; - - buildParams?( - params: Record, - ctx: TraitContext, - ): Record | undefined; - - toolCallIdPolicy?(ctx: TraitContext): ToolCallIdPolicy | undefined; - - toolMessageConversion?(ctx: TraitContext): ToolMessageConversion | undefined; - - convertError?(error: unknown, ctx: TraitContext): LlmRemoteErrorMessage | undefined; - - cacheKey?(key: string, ctx: TraitContext): Record | undefined; - - withThinking?( - thinking: ThinkingRequestOptions, - ctx: TraitContext, - ): Record | undefined; - - preserveThinking?( - thinking: ThinkingRequestOptions, - ctx: TraitContext, - ): boolean | undefined; - - withMaxCompletionTokens?( - maxCompletionTokens: number, - ctx: TraitContext, - ): Record | undefined; - - extractUsage?( - chunk: Record, - ctx: TraitContext, - ): Record | null | undefined; - - reasoningKey?(ctx: TraitContext): string | undefined; - - capability?(modelName: string): ModelCapability | undefined; - - acceptedImageMimes?(ctx: TraitContext): ReadonlySet | undefined; -} - -export interface ThinkingApplication { - readonly kwargs: Record; - readonly preserveThinking: boolean; -} - -export function resolveModelConnection( - model: LlmModel, - trait: ProtocolTrait | undefined, -): LlmModel { - const declaration = trait?.endpoint?.({ model }); - if (declaration === undefined) { - return model; - } - const read = (envName: string | undefined): string | undefined => { - if (envName === undefined) { - return undefined; - } - const value = process.env[envName]; - return value !== undefined && value.length > 0 ? value : undefined; - }; - return { - ...model, - baseUrl: model.baseUrl ?? read(declaration.baseUrlEnv) ?? declaration.defaultBaseUrl, - apiKey: model.apiKey ?? read(declaration.apiKeyEnv), - }; -} - -export type ThinkingFallback = ( - thinking: ThinkingRequestOptions, - ctx: TraitContext, -) => Record | undefined; - -export function applyThinking( - kwargs: Record, - thinking: ThinkingRequestOptions, - trait: ProtocolTrait | undefined, - ctx: TraitContext, - fallback?: ThinkingFallback, -): ThinkingApplication { - const hooked = trait?.withThinking?.(thinking, ctx) ?? fallback?.(thinking, ctx); - return { - kwargs: hooked === undefined ? kwargs : { ...kwargs, ...hooked }, - preserveThinking: trait?.preserveThinking?.(thinking, ctx) ?? false, - }; -} diff --git a/packages/agent-core-v2/src/human/llm/provider-catalog.ts b/packages/agent-core-v2/src/human/llm/provider-catalog.ts index df8f6b37728..86dee8c7aac 100644 --- a/packages/agent-core-v2/src/human/llm/provider-catalog.ts +++ b/packages/agent-core-v2/src/human/llm/provider-catalog.ts @@ -4,7 +4,7 @@ import type { LlmModel } from '#/llm/model'; import type { ProtocolName } from '#/llm/protocol/base'; import type { Provider } from '#/llm/provider/definition'; import type { LlmRequester } from '#/llm/requester/requester'; -import { assign, createActor, emit, enqueueActions, fromPromise, setup } from '#/xstate2'; +import { assign, createActor, emit, enqueueActions, fromPromise, setup, type Actor } from '#/xstate2'; export interface CatalogOAuthRef { readonly storage: 'file' | 'keyring'; @@ -47,6 +47,7 @@ export interface CatalogProviderInfo { readonly type?: string; readonly apiKey?: string; readonly baseUrl?: string; + readonly proxyUrl?: string; readonly customHeaders?: Readonly>; readonly defaultModel?: string; readonly oauth?: CatalogOAuthRef; @@ -546,6 +547,11 @@ export interface ProviderCatalog { info?: CatalogProviderInfo; models?: readonly CatalogModelDefinition[]; }): void; + upsertEntry(input: { + providerId: string; + info?: CatalogProviderInfo; + models?: readonly CatalogModelDefinition[]; + }): void; remove(providerId: string): void; refresh(provider: Provider): void; ping(providerId: string, model: string): void; @@ -656,9 +662,24 @@ export async function createProviderCatalog( const loaded = options.snapshot ?? (await options.store?.load()); const actor = createActor(createProviderCatalogMachine(), { input: loaded }); actor.start(); + return buildProviderCatalog(actor, options.store); +} + +export function createProviderCatalogSync( + options: { + snapshot?: CatalogSnapshot; + } = {}, +): ProviderCatalog { + const actor = createActor(createProviderCatalogMachine(), { input: options.snapshot }); + actor.start(); + return buildProviderCatalog(actor, undefined); +} - if (options.store !== undefined) { - const store = options.store; +function buildProviderCatalog( + actor: Actor>, + store: ProviderCatalogStore | undefined, +): ProviderCatalog { + if (store !== undefined) { actor.on('changed', () => { void store.save(actor.getSnapshot().context.snapshot); }); @@ -684,6 +705,14 @@ export async function createProviderCatalog( }); actor.send({ type: 'refresh', providers: [input.provider] }); }, + upsertEntry: (input) => { + actor.send({ + type: 'upsert', + providerId: input.providerId, + info: input.info, + models: input.models, + }); + }, remove: (providerId) => { live.delete(providerId); actor.send({ type: 'remove', providerId }); diff --git a/packages/agent-core-v2/src/human/llm/provider/definition.ts b/packages/agent-core-v2/src/human/llm/provider/definition.ts index acb34976e0a..52b1bf7273f 100644 --- a/packages/agent-core-v2/src/human/llm/provider/definition.ts +++ b/packages/agent-core-v2/src/human/llm/provider/definition.ts @@ -2,12 +2,28 @@ import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm/capability'; import type { ProviderMediaContribution } from '#/llm/media/upload'; import type { LlmConnection, LlmModel } from '#/llm/model'; import type { ProtocolBase, ProtocolName } from '#/llm/protocol/base'; -import type { ProtocolTrait } from '#/llm/protocol/trait'; -import type { LlmRequester } from '#/llm/requester/requester'; +import type { ProviderConnection } from '#/llm/protocol/connection'; +import type { AnthropicTrait } from '#/llm/requester/bases/anthropic/trait'; +import type { GoogleGenAITrait } from '#/llm/requester/bases/google-genai/trait'; +import type { OpenAIResponsesTrait } from '#/llm/requester/bases/openai-responses/trait'; +import type { OpenAITrait } from '#/llm/requester/bases/openai/trait'; +import type { LlmErrorClassifier, LlmRequester } from '#/llm/requester/requester'; -export interface ProtocolVariant { - readonly base: ProtocolBase; - readonly trait?: ProtocolTrait; +export interface ProtocolTraitMap { + readonly openai: OpenAITrait; + readonly openai_responses: OpenAIResponsesTrait; + readonly anthropic: AnthropicTrait; + readonly 'google-genai': GoogleGenAITrait; +} + +export type AnyProtocolTrait = ProtocolTraitMap[ProtocolName]; + +export interface ProtocolVariant { + readonly base: ProtocolBase; + readonly trait?: ProtocolTraitMap[N]; + readonly connection?: ProviderConnection; + readonly convertError?: LlmErrorClassifier; + readonly capability?: (modelName: string) => ModelCapability | undefined; } export interface LlmModelSeed { @@ -22,7 +38,7 @@ export type ProviderModelSource = () => Promise; export interface ProviderDefinition { readonly id: string; - readonly protocols: Readonly>>; + readonly protocols: Readonly<{ [N in ProtocolName]?: ProtocolVariant }>; readonly media?: ProviderMediaContribution; readonly models?: ProviderModelSource; } @@ -67,9 +83,7 @@ export function createProvider(definition: ProviderDefinition): Provider { }; const detectCapability = (variant: ProtocolVariant, modelName: string): ModelCapability => - variant.trait?.capability?.(modelName) ?? - variant.base.capability?.(modelName) ?? - UNKNOWN_CAPABILITY; + variant.capability?.(modelName) ?? variant.base.capability?.(modelName) ?? UNKNOWN_CAPABILITY; return { id: definition.id, @@ -84,7 +98,7 @@ export function createProvider(definition: ProviderDefinition): Provider { provider: definition.id, model: seed.model, capability: - defaultVariant.trait?.capability?.(seed.model) ?? + defaultVariant.capability?.(seed.model) ?? seed.capability ?? defaultVariant.base.capability?.(seed.model) ?? UNKNOWN_CAPABILITY, @@ -105,7 +119,11 @@ export function createProvider(definition: ProviderDefinition): Provider { }), createRequester: (protocol) => { const variant = variantFor(protocol); - return variant.base.createRequester(variant.trait); + return variant.base.createRequester({ + connection: variant.connection, + trait: variant.trait, + convertError: variant.convertError, + }); }, }; } diff --git a/packages/agent-core-v2/src/human/llm/provider/providers/standard.ts b/packages/agent-core-v2/src/human/llm/provider/providers/standard.ts index c3c206cf459..a8980295516 100644 --- a/packages/agent-core-v2/src/human/llm/provider/providers/standard.ts +++ b/packages/agent-core-v2/src/human/llm/provider/providers/standard.ts @@ -1,19 +1,19 @@ -import type { ProtocolTrait } from '#/llm/protocol/trait'; +import type { ProviderConnection } from '#/llm/protocol/connection'; import { createProvider } from '#/llm/provider/definition'; import { anthropicBase } from '#/llm/requester/bases/anthropic/requester'; import { googleGenAIBase } from '#/llm/requester/bases/google-genai/requester'; import { openAIBase } from '#/llm/requester/bases/openai/requester'; import { openAIResponsesBase } from '#/llm/requester/bases/openai-responses/requester'; -const openAITrait: ProtocolTrait = { +const openAIConnection: ProviderConnection = { endpoint: () => ({ apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }), }; -const anthropicTrait: ProtocolTrait = { +const anthropicConnection: ProviderConnection = { endpoint: () => ({ apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }), }; -export const googleGenAITrait: ProtocolTrait = { +export const googleGenAIConnection: ProviderConnection = { endpoint: (ctx) => ctx?.model.vertexai === true ? { apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' } @@ -23,22 +23,22 @@ export const googleGenAITrait: ProtocolTrait = { export const openaiProvider = createProvider({ id: 'openai', protocols: { - openai: { base: openAIBase, trait: openAITrait }, - openai_responses: { base: openAIResponsesBase, trait: openAITrait }, + openai: { base: openAIBase, connection: openAIConnection }, + openai_responses: { base: openAIResponsesBase, connection: openAIConnection }, }, }); export const anthropicProvider = createProvider({ id: 'anthropic', protocols: { - anthropic: { base: anthropicBase, trait: anthropicTrait }, + anthropic: { base: anthropicBase, connection: anthropicConnection }, }, }); export const googleProvider = createProvider({ id: 'google', protocols: { - 'google-genai': { base: googleGenAIBase, trait: googleGenAITrait }, + 'google-genai': { base: googleGenAIBase, connection: googleGenAIConnection }, }, media: { inlineVideo: true }, }); diff --git a/packages/agent-core-v2/src/human/llm/requester/actor.ts b/packages/agent-core-v2/src/human/llm/requester/actor.ts new file mode 100644 index 00000000000..0ca257f497d --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/actor.ts @@ -0,0 +1,95 @@ +import { fromCallback } from '#/xstate2'; + +import { applyCredential } from '#/credentials/credentials'; +import { isAbortError, toLlmErrorMessage } from '#/llm/errors'; +import type { Message } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; + +import type { + LlmRequestConfig, + LlmRequestContent, + LlmRequestEvent, + LlmRequester, +} from './requester'; +import type { LlmRecoveryRecord } from './recovery'; + +export interface LlmInput { + readonly config: LlmRequestConfig; + readonly content: LlmRequestContent; + readonly signal: AbortSignal; +} + +export interface MessageResolveContext { + readonly model: LlmModel; + readonly signal: AbortSignal; +} + +export interface MessageResolver { + readonly id: string; + resolve( + messages: readonly Message[], + ctx: MessageResolveContext, + ): Promise; +} + +export type LlmEvent = + | Exclude + | { type: 'llm.sent'; recovery?: LlmRecoveryRecord } + | { + type: 'llm.retrying'; + failedAttempt: number; + nextAttempt: number; + maxAttempts: number; + delayMs: number; + errorName: string; + errorMessage: string; + statusCode?: number; + } + | { + type: 'llm.recovering'; + strategy: string; + action: string; + errorName: string; + errorMessage: string; + statusCode?: number; + }; + +export function createRequestActor( + requester: LlmRequester, + messageResolvers: readonly MessageResolver[] = [], +) { + return fromCallback(({ input, sendBack }) => { + void (async () => { + try { + const credential = input.config.credentials?.resolve(); + const config = + credential === undefined + ? input.config + : credential instanceof Promise + ? { + ...input.config, + model: applyCredential(input.config.model, await credential), + } + : { ...input.config, model: applyCredential(input.config.model, credential) }; + let messages = input.content.messages; + for (const resolver of messageResolvers) { + messages = await resolver.resolve(messages, { + model: config.model, + signal: input.signal, + }); + } + await requester.generate( + config, + { ...input.content, messages }, + { + signal: input.signal, + onEvent: sendBack, + }, + ); + } catch (error) { + if (isAbortError(error) || input.signal.aborted) return; + sendBack({ type: 'llm.failed.remote', error: toLlmErrorMessage(error), rawError: error }); + } + })(); + }); +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/contract.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/contract.ts new file mode 100644 index 00000000000..8b0ea8718db --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/contract.ts @@ -0,0 +1,73 @@ +export const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; + +export type AnthropicWireContentBlock = + | { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } } + | { + type: 'image'; + source: { type: 'base64'; data: string; media_type: string } | { type: 'url'; url: string }; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'video'; + source: { type: 'base64'; media_type: string; data: string } | { type: 'url'; url: string }; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'thinking'; + thinking: string; + signature?: string; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'tool_result'; + tool_use_id: string; + content: AnthropicWireContentBlock[]; + cache_control?: { type: 'ephemeral' }; + }; + +export type AnthropicWireMessage = { + role: 'user' | 'assistant'; + content: AnthropicWireContentBlock[]; +}; + +export type AnthropicRawUsage = { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; +}; + +export type AnthropicRawContentBlock = { + type: string; + text?: string; + thinking?: string; + signature?: string; + data?: string; + id?: string; + name?: string; + input?: unknown; +}; + +export type AnthropicRawStreamEvent = { + type: string; + index?: number; + content_block?: AnthropicRawContentBlock; + delta?: { + type?: string; + text?: string; + thinking?: string; + partial_json?: string; + signature?: string; + stop_reason?: string | null; + stop_sequence?: string | null; + }; + message?: { id?: string; usage?: AnthropicRawUsage }; + usage?: AnthropicRawUsage; +}; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts index 8d9f16bc1ff..363d8b26496 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts @@ -3,7 +3,6 @@ import Anthropic, { APIConnectionTimeoutError as RawAnthropicSDKConnectionTimeoutError, APIError as RawAnthropicSDKAPIError, } from '@anthropic-ai/sdk'; -import { assign, shake } from 'radashi'; import { headersToRecord, @@ -18,33 +17,26 @@ import { NO_FINISH, type FinishInfo, type FinishReason } from '#/llm/finish-reas import type { FormatRequestInput, ProtocolFormat } from '#/llm/protocol/format'; import type { ResponseFormat } from '#/llm/response-format'; import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; -import type { ToolDescription } from '#/llm/message'; -import { applyThinking } from '#/llm/protocol/trait'; +import type { Message, ToolDescription } from '#/llm/message'; import { mergeConsecutiveUsers } from '#/llm/protocol/patterns'; import { applyPatterns } from '#/llm/protocol/rewrite'; import type { TokenUsage } from '#/llm/usage'; -import { lowerMessage, messageContent, type AnthropicWireMessage } from './lower'; +import { CONTEXT_MANAGEMENT_BETA } from './contract'; +import type { + AnthropicRawStreamEvent, + AnthropicRawUsage, + AnthropicWireMessage, +} from './contract'; +import { lowerMessage, messageContent } from './lower'; import { audioToPlaceholder, stripUnsignedThinking } from './patterns'; import { - encodeThinking, - INTERLEAVED_THINKING_BETA, resolveDefaultMaxTokens, shouldPreserveUnsignedThinking, } from './profile'; -export { INTERLEAVED_THINKING_BETA } from './profile'; -export const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; - const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; -type RawUsage = { - input_tokens?: number | null; - output_tokens?: number | null; - cache_read_input_tokens?: number | null; - cache_creation_input_tokens?: number | null; -}; - const CACHE_CONTROL = { type: 'ephemeral' as const }; const CACHEABLE_TYPES = new Set([ @@ -58,8 +50,6 @@ const CACHEABLE_TYPES = new Set([ 'web_search_tool_result', ]); -export type { AnthropicWireContentBlock, AnthropicWireMessage } from './lower'; - function injectCacheControlOnLastBlock(messages: AnthropicWireMessage[]): void { const lastMessage = messages.at(-1); if (lastMessage === undefined) return; @@ -78,38 +68,6 @@ function isToolResultOnly(message: AnthropicWireMessage): boolean { return content.every((block) => block.type === 'tool_result'); } -interface RawContentBlock { - type: string; - text?: string; - thinking?: string; - signature?: string; - data?: string; - id?: string; - name?: string; - input?: unknown; -} - -interface RawStreamDelta { - type?: string; - text?: string; - thinking?: string; - partial_json?: string; - signature?: string; - stop_reason?: string | null; - stop_sequence?: string | null; -} - -interface RawStreamEvent { - type: string; - index?: number; - content_block?: RawContentBlock; - delta?: RawStreamDelta; - message?: { id?: string; usage?: RawUsage }; - usage?: RawUsage; -} - -type RawResponse = { content?: RawContentBlock[]; usage?: RawUsage }; - function normalizeStopReason(raw: string | null | undefined): FinishInfo { if (raw === null || raw === undefined) { return NO_FINISH; @@ -134,7 +92,7 @@ function normalizeStopReason(raw: string | null | undefined): FinishInfo { return { finishReason, rawFinishReason: raw }; } -function parseRawUsage(usage: RawUsage | undefined): Partial | undefined { +function parseRawUsage(usage: AnthropicRawUsage | undefined): Partial | undefined { if (usage === undefined) { return undefined; } @@ -154,7 +112,7 @@ function parseRawUsage(usage: RawUsage | undefined): Partial | undef return patch; } -function applyResponseFormat( +export function applyAnthropicResponseFormat( kwargs: Record, format: ResponseFormat, ): Record { @@ -172,7 +130,10 @@ function applyResponseFormat( return { ...kwargs, output_config: outputConfig }; } -function applyThinkingKeep(kwargs: Record, keep: string): Record { +export function applyAnthropicThinkingKeep( + kwargs: Record, + keep: string, +): Record { const betaFeatures = kwargs['betaFeatures']; const existing = kwargs['context_management'] as | { edits?: Array<{ type: string }> } @@ -193,50 +154,49 @@ function applyThinkingKeep(kwargs: Record, keep: string): Recor }; } -function resolveRequestKwargs(input: FormatRequestInput): Record { - const { - trait, - ctx, - thinking, - responseFormat, - maxCompletionTokens, - usedContextTokens, - maxContextTokens, - extraParams, - } = input; - let kwargs: Record = { betaFeatures: [INTERLEAVED_THINKING_BETA] }; - if (thinking !== undefined) { - kwargs = applyThinking(kwargs, thinking, trait, ctx, (t, c) => - encodeThinking(t, c.model), - ).kwargs; - } - if (responseFormat !== undefined) { - kwargs = applyResponseFormat(kwargs, responseFormat); - } - if (maxCompletionTokens !== undefined) { - let cap = maxCompletionTokens; - if ( - usedContextTokens !== undefined && - maxContextTokens !== undefined && - maxContextTokens > 0 - ) { - cap = Math.min(cap, maxContextTokens - usedContextTokens); - } - cap = Math.max(1, cap); - cap = resolveDefaultMaxTokens(ctx.model.model, cap); - const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - kwargs = { ...kwargs, max_tokens: cap }; - } - } - kwargs = assign(kwargs, extraParams?.anthropic ?? {}); - if (thinking?.keep !== undefined) { - kwargs = applyThinkingKeep(kwargs, thinking.keep); - } - kwargs = shake(kwargs); - return kwargs; +export function encodeAnthropicMaxTokens(cap: number): Record { + return { max_tokens: cap }; +} + +export function defaultAnthropicTool(tool: ToolDescription): Record { + return { + name: tool.name, + description: tool.description, + input_schema: tool.parameters, + }; +} + +export function defaultAnthropicMergeHistory( + messages: readonly AnthropicWireMessage[], +): AnthropicWireMessage[] { + return applyPatterns(messages, [ + mergeConsecutiveUsers({ + isUser: (param) => param.role === 'user', + isToolResultOnly, + merge: (last, next) => ({ + ...last, + content: [...messageContent(last), ...messageContent(next)], + }), + }), + ]); +} + +export interface AnthropicLoweredMessage { + readonly source: Message; + readonly message: AnthropicWireMessage; +} + +export function lowerAnthropicRequest( + input: FormatRequestInput, + acceptedMimes: ReadonlySet, +): AnthropicLoweredMessage[] { + const normalized = applyPatterns(input.messages, [ + stripUnsignedThinking({ preserve: shouldPreserveUnsignedThinking(input.model.model) }), + audioToPlaceholder, + ]); + return normalized.flatMap((message) => + lowerMessage(message, acceptedMimes).map((wire) => ({ source: message, message: wire })), + ); } export interface AnthropicRequestParams { @@ -250,71 +210,70 @@ export interface AnthropicFormatOptions { readonly betaApi?: boolean; } -function sessionHeadersForRequest(input: FormatRequestInput): Record | undefined { +export interface AnthropicRequestParts { + readonly messages: readonly AnthropicWireMessage[]; + readonly tools: readonly Record[]; + readonly kwargs: Readonly>; + readonly betaApi: boolean; +} + +export interface AnthropicRequestAssembly { + readonly params: Record; + readonly betas: readonly string[]; + readonly useBetaApi: boolean; +} + +export function assembleAnthropicRequest( + input: FormatRequestInput, + parts: AnthropicRequestParts, +): AnthropicRequestAssembly { + const messages = [...parts.messages]; + injectCacheControlOnLastBlock(messages); + const tools = parts.tools.map((tool) => ({ ...tool })); + const lastTool = tools.at(-1); + if (lastTool !== undefined) { + lastTool['cache_control'] = CACHE_CONTROL; + } + const { betaFeatures, ...restKwargs } = parts.kwargs; + const betas = Array.isArray(betaFeatures) ? (betaFeatures as string[]) : []; + const useBetaApi = + parts.betaApi || input.model.betaApi === true || input.thinking?.keep !== undefined; + const params: Record = { + model: input.model.model, + max_tokens: resolveDefaultMaxTokens(input.model.model), + metadata: input.cacheKey === undefined ? undefined : { user_id: input.cacheKey }, + ...restKwargs, + system: input.systemPrompt + ? [{ type: 'text', text: input.systemPrompt, cache_control: CACHE_CONTROL }] + : undefined, + messages, + tools: tools.length === 0 ? undefined : tools, + betas: useBetaApi && betas.length > 0 ? betas : undefined, + stream: true, + }; + return { params, betas, useBetaApi }; +} + +export function encodeAnthropicRequest( + assembly: AnthropicRequestAssembly, +): AnthropicRequestParams { + return { + params: assembly.params as unknown as Anthropic.MessageCreateParamsStreaming, + betas: assembly.betas, + useBetaApi: assembly.useBetaApi, + }; +} + +export function sessionHeadersForRequest( + input: FormatRequestInput, +): Record | undefined { const { cacheKey } = input; if (cacheKey === undefined) return undefined; return { 'x-opencode-session': cacheKey }; } -export function createAnthropicFormat( - options?: AnthropicFormatOptions, -): ProtocolFormat { - const betaApi = options?.betaApi === true; +export function createAnthropicFormat(): ProtocolFormat { return { - formatRequest(input) { - const { messages, systemPrompt, tools, trait, ctx, cacheKey, thinking } = input; - const kwargs = resolveRequestKwargs(input); - const normalized = applyPatterns(messages, [ - stripUnsignedThinking({ preserve: shouldPreserveUnsignedThinking(ctx.model.model) }), - audioToPlaceholder, - ]); - const converted = normalized.flatMap((message) => lowerMessage(message, { trait, ctx })); - const merged = - (trait?.mergeHistory?.(converted, ctx) as AnthropicWireMessage[] | undefined) ?? - applyPatterns(converted, [ - mergeConsecutiveUsers({ - isUser: (param) => param.role === 'user', - isToolResultOnly, - merge: (last, next) => ({ - ...last, - content: [...messageContent(last), ...messageContent(next)], - }), - }), - ]); - injectCacheControlOnLastBlock(merged); - const formattedTools: Record[] = tools.map( - (tool) => trait?.convertTool?.(tool, ctx) ?? defaultConvertTool(tool), - ); - const lastTool = formattedTools.at(-1); - if (lastTool !== undefined) { - lastTool['cache_control'] = CACHE_CONTROL; - } - const { betaFeatures, ...restKwargs } = kwargs; - const betas = Array.isArray(betaFeatures) ? (betaFeatures as string[]) : []; - const useBetaApi = betaApi || ctx.model.betaApi === true || thinking?.keep !== undefined; - const createParams: Record = { - model: ctx.model.model, - max_tokens: resolveDefaultMaxTokens(ctx.model.model), - metadata: cacheKey === undefined ? undefined : { user_id: cacheKey }, - ...restKwargs, - system: systemPrompt - ? [{ type: 'text', text: systemPrompt, cache_control: CACHE_CONTROL }] - : undefined, - messages: merged, - tools: formattedTools.length === 0 ? undefined : formattedTools, - betas: useBetaApi && betas.length > 0 ? betas : undefined, - stream: true, - }; - const finalParams = trait?.buildParams?.(createParams, ctx) ?? createParams; - const headers = sessionHeadersForRequest(input); - return { - params: finalParams as unknown as Anthropic.MessageCreateParamsStreaming, - betas, - useBetaApi, - ...(headers !== undefined ? { headers } : {}), - }; - }, - createStreamParser() { return (chunk, sink) => { if (chunk.type === 'message_start') { @@ -392,16 +351,7 @@ export function createAnthropicFormat( }; } -export const anthropicFormat: ProtocolFormat = - createAnthropicFormat(); - -function defaultConvertTool(tool: ToolDescription): Record { - return { - name: tool.name, - description: tool.description, - input_schema: tool.parameters, - }; -} +export const anthropicFormat: ProtocolFormat = createAnthropicFormat(); export function convertAnthropicError( error: unknown, diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/lower.ts index 3fa0e5b0fbb..0bfbaba1f7a 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/lower.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/lower.ts @@ -1,44 +1,7 @@ import type { Message, TextPart } from '#/llm/message'; -import type { ProtocolTrait, TraitContext } from '#/llm/protocol/trait'; import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; -import { providerImagePolicy } from '#/llm/media/image-formats'; -export type AnthropicWireContentBlock = - | { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } } - | { - type: 'image'; - source: { type: 'base64'; data: string; media_type: string } | { type: 'url'; url: string }; - cache_control?: { type: 'ephemeral' }; - } - | { - type: 'video'; - source: { type: 'base64'; media_type: string; data: string } | { type: 'url'; url: string }; - cache_control?: { type: 'ephemeral' }; - } - | { - type: 'thinking'; - thinking: string; - signature?: string; - cache_control?: { type: 'ephemeral' }; - } - | { - type: 'tool_use'; - id: string; - name: string; - input: unknown; - cache_control?: { type: 'ephemeral' }; - } - | { - type: 'tool_result'; - tool_use_id: string; - content: AnthropicWireContentBlock[]; - cache_control?: { type: 'ephemeral' }; - }; - -export type AnthropicWireMessage = { - role: 'user' | 'assistant'; - content: AnthropicWireContentBlock[]; -}; +import type { AnthropicWireContentBlock, AnthropicWireMessage } from './contract'; type AnthropicWireImageBlock = Extract; @@ -124,17 +87,14 @@ export function messageContent(message: AnthropicWireMessage): AnthropicWireCont return Array.isArray(message.content) ? message.content : []; } -export interface AnthropicLowerContext { - readonly trait: ProtocolTrait | undefined; - readonly ctx: TraitContext; +export function isAnthropicWireMessageEmpty(message: AnthropicWireMessage): boolean { + return messageContent(message).length === 0; } export function lowerMessage( message: Message, - lower: AnthropicLowerContext, + acceptedMimes: ReadonlySet, ): AnthropicWireMessage[] { - const { trait, ctx } = lower; - const acceptedMimes = trait?.acceptedImageMimes?.(ctx) ?? providerImagePolicy().acceptedMimes; const content: AnthropicWireContentBlock[] = []; if (message.role === 'system') { const text = message.content @@ -191,15 +151,5 @@ export function lowerMessage( role: message.role === 'assistant' ? 'assistant' : 'user', content, }; - const hooked = - trait?.convertMessage === undefined - ? converted - : (trait.convertMessage(message, converted, ctx) as AnthropicWireMessage | null); - if (hooked === null) { - return []; - } - if (messageContent(hooked).length === 0) { - return []; - } - return [hooked]; + return [converted]; } diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/requester.ts index 7e488a5a762..6489c1d4c68 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/requester.ts @@ -1,11 +1,15 @@ import Anthropic from '@anthropic-ai/sdk'; +import { assign, shake } from 'radashi'; import { ProxyAgent, type Dispatcher } from 'undici'; import { headersToRecord } from '#/llm/errors'; +import { providerImagePolicy } from '#/llm/media/image-formats'; import type { LlmModel } from '#/llm/model'; import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; -import type { ProtocolBase } from '#/llm/protocol/base'; -import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import type { ProtocolBase, ProtocolRequesterOptions, TraitContext } from '#/llm/protocol/base'; +import { resolveModelConnection } from '#/llm/protocol/connection'; +import { applyThinking } from '#/llm/protocol/thinking'; +import { resolveMaxCompletionCap, type FormatRequestInput } from '#/llm/protocol/format'; import { mergeRequestHeaders, type LlmClientContext, @@ -23,19 +27,34 @@ import { sanitizeToolCallId, } from '../tool-call-id'; import { getAnthropicModelCapability } from './capability'; +import type { AnthropicTrait } from './trait'; import { + applyAnthropicResponseFormat, + applyAnthropicThinkingKeep, + assembleAnthropicRequest, createAnthropicFormat, + defaultAnthropicMergeHistory, + defaultAnthropicTool, + encodeAnthropicMaxTokens, + encodeAnthropicRequest, + lowerAnthropicRequest, + sessionHeadersForRequest, type AnthropicFormatOptions, type AnthropicRequestParams, convertAnthropicError, } from './format'; +import { isAnthropicWireMessageEmpty } from './lower'; +import { encodeThinking, INTERLEAVED_THINKING_BETA, resolveDefaultMaxTokens } from './profile'; const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), maxLength: 64, }; -export type AnthropicBaseOptions = AnthropicFormatOptions & LlmRequesterOptions; +export interface AnthropicRequesterOptions + extends ProtocolRequesterOptions, + AnthropicFormatOptions, + LlmRequesterOptions {} function anthropicCustomHeaderEnvNames(): string[] { const customHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; @@ -82,8 +101,72 @@ function createClient(model: LlmModel, headers: Record | undefin }); } +export interface AnthropicRequestPlanOptions { + readonly trait?: AnthropicTrait; + readonly betaApi?: boolean; +} + +export function planAnthropicRequest( + input: FormatRequestInput, + options?: AnthropicRequestPlanOptions, +): AnthropicRequestParams { + const trait = options?.trait; + const ctx: TraitContext = { model: input.model }; + let kwargs: Record = { betaFeatures: [INTERLEAVED_THINKING_BETA] }; + if (input.thinking !== undefined) { + kwargs = applyThinking(kwargs, input.thinking, trait?.thinking, ctx, (t, c) => + encodeThinking(t, c.model), + ).kwargs; + } + if (input.responseFormat !== undefined) { + kwargs = applyAnthropicResponseFormat(kwargs, input.responseFormat); + } + const cap = resolveMaxCompletionCap(input); + if (cap !== undefined) { + const capped = resolveDefaultMaxTokens(ctx.model.model, cap); + kwargs = { + ...kwargs, + ...(trait?.maxCompletionTokens?.(capped, ctx) ?? encodeAnthropicMaxTokens(capped)), + }; + } + kwargs = assign(kwargs, input.extraParams?.anthropic ?? {}); + if (input.thinking?.keep !== undefined) { + kwargs = applyAnthropicThinkingKeep(kwargs, input.thinking.keep); + } + kwargs = shake(kwargs); + + const acceptedMimes = + trait?.acceptedImageMimes?.(ctx) ?? providerImagePolicy().acceptedMimes; + const lowered = lowerAnthropicRequest(input, acceptedMimes); + const converted = lowered + .flatMap(({ source, message }) => { + if (trait?.convertMessage === undefined) { + return [message]; + } + const hooked = trait.convertMessage(source, message, ctx); + return hooked === null ? [] : [hooked]; + }) + .filter((message) => !isAnthropicWireMessageEmpty(message)); + const merged = trait?.mergeHistory?.(converted, ctx) ?? defaultAnthropicMergeHistory(converted); + const tools = input.tools.map( + (tool) => trait?.convertTool?.(tool, ctx) ?? defaultAnthropicTool(tool), + ); + const assembly = assembleAnthropicRequest(input, { + messages: merged, + tools, + kwargs, + betaApi: options?.betaApi === true, + }); + const finalParams = trait?.buildParams?.(assembly.params, ctx) ?? assembly.params; + const headers = sessionHeadersForRequest(input); + return { + ...encodeAnthropicRequest({ ...assembly, params: finalParams }), + ...(headers !== undefined ? { headers } : {}), + }; +} + interface AnthropicTransport { - readonly trait: ProtocolTrait | undefined; + readonly connection: AnthropicRequesterOptions['connection']; readonly ctx: TraitContext; readonly format: ReturnType; readonly resolveClient: (request: LlmClientContext) => Anthropic; @@ -95,10 +178,10 @@ async function internalGenerate( request: AnthropicRequestParams, transport: AnthropicTransport, ): Promise { - const { trait, ctx, format, resolveClient, signal, onEvent } = transport; + const { connection, ctx, format, resolveClient, signal, onEvent } = transport; const client = resolveClient({ model: ctx.model, - headers: mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + headers: mergeRequestHeaders(connection?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), }); onEvent?.({ type: 'llm.sent' }); const betaHeaders = @@ -109,13 +192,13 @@ async function internalGenerate( const mergedHeaders = betaHeaders === undefined && sessionHeaders === undefined ? undefined - : { ...(sessionHeaders ?? {}), ...(betaHeaders ?? {}) }; + : { ...sessionHeaders, ...betaHeaders }; const requestOptions = { signal, ...(mergedHeaders !== undefined ? { headers: mergedHeaders } : {}) }; const { data: stream, response } = request.useBetaApi ? await client.beta.messages.create(request.params, requestOptions).withResponse() : await client.messages.create(request.params, requestOptions).withResponse(); onEvent?.({ type: 'llm.streaming.headers', headers: headersToRecord(response.headers) ?? {} }); - const parse = format.createStreamParser({ trait, ctx }); + const parse = format.createStreamParser(); let messageId: string | undefined; for await (const event of stream) { let failed = false; @@ -140,11 +223,11 @@ async function internalGenerate( onEvent?.({ type: 'llm.done' }); } -export function createAnthropicRequester( - trait?: ProtocolTrait, - options?: AnthropicBaseOptions, -): LlmRequester { - const format = createAnthropicFormat(options); +export function createAnthropicRequester(options?: AnthropicRequesterOptions): LlmRequester { + const connection = options?.connection; + const trait = options?.trait; + const convertError = options?.convertError; + const format = createAnthropicFormat(); const resolveClient = options?.clientFactory ?? ((request: LlmClientContext) => createClient(request.model, request.headers)); @@ -154,52 +237,58 @@ export function createAnthropicRequester( content: LlmRequestContent, control: LlmRequestControl, ): Promise { - const model = resolveModelConnection(config.model, trait); - const { systemPrompt, tools = [] } = config; + const model = resolveModelConnection(config.model, connection); + const { tools = [] } = config; const { messages } = content; const { signal, onEvent } = control; const ctx: TraitContext = { model }; let request: AnthropicRequestParams; try { - const policy = trait?.toolCallIdPolicy?.(ctx) ?? ANTHROPIC_TOOL_CALL_ID_POLICY; - request = format.formatRequest({ - model, - messages: normalizeToolCallIdsForProvider(messages, policy), - systemPrompt, - tools, - trait, - ctx, - cacheKey: config.cacheKey, - thinking: config.thinking, - responseFormat: config.responseFormat, - maxCompletionTokens: config.maxCompletionTokens, - usedContextTokens: content.usedContextTokens, - maxContextTokens: config.maxContextTokens, - extraParams: config.extraParams, - }); + const policy = trait?.toolCallIdPolicy ?? ANTHROPIC_TOOL_CALL_ID_POLICY; + request = planAnthropicRequest( + { + ...config, + model, + messages: normalizeToolCallIdsForProvider(messages, policy), + tools, + usedContextTokens: content.usedContextTokens, + }, + { trait, betaApi: options?.betaApi }, + ); } catch (error) { onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); return; } try { - await internalGenerate(request, { trait, ctx, format, resolveClient, signal, onEvent }); + await internalGenerate(request, { + connection, + ctx, + format, + resolveClient, + signal, + onEvent, + }); } catch (error) { onEvent?.({ type: 'llm.failed.remote', - error: convertAnthropicError(error, (e) => trait?.convertError?.(e, ctx)), + error: convertAnthropicError(error, (e) => convertError?.(e)), }); } }, }; } -export function createAnthropicBase(options?: AnthropicBaseOptions): ProtocolBase { +export function createAnthropicBase( + options?: AnthropicFormatOptions & LlmRequesterOptions, +): ProtocolBase { return { capability: getAnthropicModelCapability, - createRequester: (trait?: ProtocolTrait) => createAnthropicRequester(trait, options), + createRequester: (requesterOptions) => createAnthropicRequester({ ...options, ...requesterOptions }), }; } -export const anthropicBase: ProtocolBase = createAnthropicBase(); +export const anthropicBase: ProtocolBase = createAnthropicBase(); -export const anthropicBetaBase: ProtocolBase = createAnthropicBase({ betaApi: true }); +export const anthropicBetaBase: ProtocolBase = createAnthropicBase({ + betaApi: true, +}); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/trait.ts new file mode 100644 index 00000000000..73489ce3400 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/trait.ts @@ -0,0 +1,37 @@ +import type { Message, ToolDescription } from '#/llm/message'; +import type { TraitContext } from '#/llm/protocol/base'; +import type { ThinkingStrategy } from '#/llm/protocol/thinking'; +import type { ToolCallIdPolicy } from '#/llm/requester/requester'; + +import type { AnthropicWireMessage } from './contract'; + +export interface AnthropicTrait { + readonly toolCallIdPolicy?: ToolCallIdPolicy; + + readonly thinking?: ThinkingStrategy; + + maxCompletionTokens?( + maxCompletionTokens: number, + ctx: TraitContext, + ): Record | undefined; + + convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; + + acceptedImageMimes?(ctx: TraitContext): ReadonlySet | undefined; + + convertMessage?( + message: Message, + converted: AnthropicWireMessage, + ctx: TraitContext, + ): AnthropicWireMessage | null; + + mergeHistory?( + messages: readonly AnthropicWireMessage[], + ctx: TraitContext, + ): AnthropicWireMessage[] | undefined; + + buildParams?( + params: Record, + ctx: TraitContext, + ): Record | undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/contract.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/contract.ts new file mode 100644 index 00000000000..b4eab0f6530 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/contract.ts @@ -0,0 +1,18 @@ +export type GoogleContent = { + role: 'user' | 'model'; + parts: GooglePart[]; +}; + +export type GooglePart = { + text?: string; + thought?: boolean; + thoughtSignature?: string; + inlineData?: { mimeType: string; data: string }; + fileData?: { fileUri: string; mimeType: string }; + functionCall?: { name: string; args: Record }; + functionResponse?: { + name: string; + response: Record; + parts: GooglePart[]; + }; +}; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/format.ts index 4de9a8200a5..b7b75b1a11c 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/format.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/format.ts @@ -1,5 +1,4 @@ import { ApiError as RawGoogleGenAISDKApiError, type GenerateContentParameters } from '@google/genai'; -import { assign, shake } from 'radashi'; import { isAbortError, @@ -17,17 +16,16 @@ import type { ToolDescription, } from '#/llm/message'; import type { ThinkingEffort } from '#/llm/thinking'; -import { applyThinking } from '#/llm/protocol/trait'; import { mergeConsecutiveUsers } from '#/llm/protocol/patterns'; import { applyPatterns } from '#/llm/protocol/rewrite'; +import type { ResponseFormat } from '#/llm/response-format'; import type { TokenUsage } from '#/llm/usage'; -import { buildToolNameById, lowerMessage, type GoogleContent } from './lower'; +import { buildToolNameById, lowerMessage } from './lower'; +import type { GoogleContent } from './contract'; import { sortToolRunByCallOrder } from './patterns'; -export type { GoogleContent, GooglePart } from './lower'; - -function toolToGoogleGenAI(tool: ToolDescription): Record { +export function toolToGoogleGenAI(tool: ToolDescription): Record { return { functionDeclarations: [ { @@ -147,7 +145,10 @@ function extractChunkParts(response: Record): StreamedMessagePa return parts; } -function encodeThinking(model: string, effort: ThinkingEffort): Record { +export function encodeGoogleGenAIThinking( + model: string, + effort: ThinkingEffort, +): Record { if (model.includes('gemini-3')) { switch (effort) { case 'off': @@ -180,51 +181,20 @@ function encodeThinking(model: string, effort: ThinkingEffort): Record { - const { - trait, - ctx, - thinking, - responseFormat, - maxCompletionTokens, - usedContextTokens, - maxContextTokens, - extraParams, - } = input; - let kwargs: Record = {}; - if (thinking !== undefined) { - kwargs = applyThinking(kwargs, thinking, trait, ctx, (t, c) => ({ - thinkingConfig: encodeThinking(c.model.model, t.effort), - })).kwargs; - } - if (maxCompletionTokens !== undefined) { - let cap = maxCompletionTokens; - if ( - usedContextTokens !== undefined && - maxContextTokens !== undefined && - maxContextTokens > 0 - ) { - cap = Math.min(cap, maxContextTokens - usedContextTokens); - } - cap = Math.max(1, cap); - const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - kwargs = { ...kwargs, maxOutputTokens: cap }; - } - } - if (responseFormat !== undefined) { - kwargs['responseMimeType'] = 'application/json'; - delete kwargs['responseSchema']; - delete kwargs['responseJsonSchema']; - if (responseFormat.type === 'json_schema') { - kwargs['responseJsonSchema'] = responseFormat.jsonSchema.schema; - } - } - kwargs = assign(kwargs, extraParams?.googleGenai ?? {}); - kwargs = shake(kwargs); - return kwargs; +export function encodeGoogleGenAIMaxOutputTokens(cap: number): Record { + return { maxOutputTokens: cap }; +} + +export function applyGoogleGenAIResponseFormat( + kwargs: Record, + format: ResponseFormat, +): Record { + const { responseSchema: _dropSchema, responseJsonSchema: _dropJsonSchema, ...rest } = kwargs; + return { + ...rest, + responseMimeType: 'application/json', + responseJsonSchema: format.type === 'json_schema' ? format.jsonSchema.schema : undefined, + }; } export interface GoogleGenAIRequestParams { @@ -232,52 +202,60 @@ export interface GoogleGenAIRequestParams { readonly headers?: Record; } -export const googleGenAIFormat: ProtocolFormat = { - formatRequest(input) { - const { messages, systemPrompt, tools, trait, ctx } = input; - const kwargs = resolveRequestKwargs(input); - const contents = messagesToGoogleGenAIContents(messages); - const finalContents = trait?.mergeHistory?.(contents, ctx) as GoogleContent[] | undefined; - const params: Record = { - model: ctx.model.model, - contents: finalContents ?? contents, - config: { - systemInstruction: systemPrompt ? systemPrompt : undefined, - tools: - tools.length === 0 - ? undefined - : tools.map((tool) => trait?.convertTool?.(tool, ctx) ?? toolToGoogleGenAI(tool)), - ...kwargs, - }, - }; - const finalParams = trait?.buildParams?.(params, ctx) ?? params; - return { params: finalParams as unknown as GenerateContentParameters }; - }, +export interface GoogleGenAIRequestParts { + readonly contents: readonly GoogleContent[]; + readonly tools: readonly Record[]; + readonly kwargs: Readonly>; +} - createStreamParser() { - return (chunk, sink) => { - const response = chunk as Record; - if (response === null || typeof response !== 'object') { - return; - } - const rawFinish = extractChunkFinishReason(response); - const responseId = response['responseId']; - if (typeof responseId === 'string' && responseId.length > 0) { - sink.onMessageId?.(responseId); - } - const usage = parseUsageMetadata(response); - if (usage !== undefined && rawFinish !== undefined && rawFinish !== null) { - sink.onUsage?.(usage); - } - if (rawFinish !== undefined && rawFinish !== null) { - sink.onFinish(normalizeFinishReason(rawFinish)); - } - for (const part of extractChunkParts(response)) { - sink.onDelta(part); - } - }; - }, -}; +export function assembleGoogleGenAIRequest( + input: FormatRequestInput, + parts: GoogleGenAIRequestParts, +): Record { + return { + model: input.model.model, + contents: parts.contents, + config: { + systemInstruction: input.systemPrompt ? input.systemPrompt : undefined, + tools: parts.tools.length === 0 ? undefined : parts.tools, + ...parts.kwargs, + }, + }; +} + +export function encodeGoogleGenAIRequest( + params: Record, +): GoogleGenAIRequestParams { + return { params: params as unknown as GenerateContentParameters }; +} + +export function createGoogleGenAIFormat(): ProtocolFormat { + return { + createStreamParser() { + return (chunk, sink) => { + const response = chunk as Record; + if (response === null || typeof response !== 'object') { + return; + } + const rawFinish = extractChunkFinishReason(response); + const responseId = response['responseId']; + if (typeof responseId === 'string' && responseId.length > 0) { + sink.onMessageId?.(responseId); + } + const usage = parseUsageMetadata(response); + if (usage !== undefined && rawFinish !== undefined && rawFinish !== null) { + sink.onUsage?.(usage); + } + if (rawFinish !== undefined && rawFinish !== null) { + sink.onFinish(normalizeFinishReason(rawFinish)); + } + for (const part of extractChunkParts(response)) { + sink.onDelta(part); + } + }; + }, + }; +} function parseUsageMetadata(response: Record): TokenUsage | undefined { const usageMetadata = response['usageMetadata'] as Record | undefined; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/lower.ts index 9d9f62880e2..639630f12d1 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/lower.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/lower.ts @@ -1,24 +1,7 @@ import type { Message, TextPart } from '#/llm/message'; import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; -export type GoogleContent = { - role: 'user' | 'model'; - parts: GooglePart[]; -}; - -export type GooglePart = { - text?: string; - thought?: boolean; - thoughtSignature?: string; - inlineData?: { mimeType: string; data: string }; - fileData?: { fileUri: string; mimeType: string }; - functionCall?: { name: string; args: Record }; - functionResponse?: { - name: string; - response: Record; - parts: GooglePart[]; - }; -}; +import type { GoogleContent, GooglePart } from './contract'; function toolCallIdToName(toolCallId: string, toolNameById: Map): string { const name = toolNameById.get(toolCallId); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/requester.ts index f6c66023d56..dd38fb1cdfe 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/requester.ts @@ -1,9 +1,12 @@ import { GoogleGenAI as GenAIClient, type GenerateContentParameters } from '@google/genai'; +import { assign, shake } from 'radashi'; import type { LlmModel } from '#/llm/model'; import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; -import type { ProtocolBase } from '#/llm/protocol/base'; -import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import type { ProtocolBase, ProtocolRequesterOptions, TraitContext } from '#/llm/protocol/base'; +import { resolveModelConnection } from '#/llm/protocol/connection'; +import { applyThinking } from '#/llm/protocol/thinking'; +import { resolveMaxCompletionCap, type FormatRequestInput } from '#/llm/protocol/format'; import { mergeRequestHeaders, type LlmClientContext, @@ -16,10 +19,62 @@ import { } from '#/llm/requester/requester'; import { getGoogleGenAIModelCapability } from './capability'; -import { convertGoogleGenAIError, googleGenAIFormat, type GoogleGenAIRequestParams } from './format'; +import type { GoogleGenAITrait } from './trait'; +import { + applyGoogleGenAIResponseFormat, + assembleGoogleGenAIRequest, + convertGoogleGenAIError, + createGoogleGenAIFormat, + encodeGoogleGenAIMaxOutputTokens, + encodeGoogleGenAIRequest, + encodeGoogleGenAIThinking, + messagesToGoogleGenAIContents, + toolToGoogleGenAI, + type GoogleGenAIRequestParams, +} from './format'; + +export interface GoogleGenAIRequesterOptions + extends ProtocolRequesterOptions, + LlmRequesterOptions { + readonly vertexai?: boolean; +} -export interface GoogleGenAIBaseOptions extends LlmRequesterOptions { - vertexai?: boolean; +export interface GoogleGenAIRequestPlanOptions { + readonly trait?: GoogleGenAITrait; +} + +export function planGoogleGenAIRequest( + input: FormatRequestInput, + options?: GoogleGenAIRequestPlanOptions, +): GoogleGenAIRequestParams { + const trait = options?.trait; + const ctx: TraitContext = { model: input.model }; + let kwargs: Record = {}; + if (input.thinking !== undefined) { + kwargs = applyThinking(kwargs, input.thinking, trait?.thinking, ctx, (t, c) => ({ + thinkingConfig: encodeGoogleGenAIThinking(c.model.model, t.effort), + })).kwargs; + } + const cap = resolveMaxCompletionCap(input); + if (cap !== undefined) { + kwargs = { + ...kwargs, + ...(trait?.maxCompletionTokens?.(cap, ctx) ?? encodeGoogleGenAIMaxOutputTokens(cap)), + }; + } + if (input.responseFormat !== undefined) { + kwargs = applyGoogleGenAIResponseFormat(kwargs, input.responseFormat); + } + kwargs = shake(assign(kwargs, input.extraParams?.googleGenai ?? {})); + + const contents = messagesToGoogleGenAIContents(input.messages); + const merged = trait?.mergeHistory?.(contents, ctx) ?? contents; + const tools = input.tools.map( + (tool) => trait?.convertTool?.(tool, ctx) ?? toolToGoogleGenAI(tool), + ); + const params = assembleGoogleGenAIRequest(input, { contents: merged, tools, kwargs }); + const finalParams = trait?.buildParams?.(params, ctx) ?? params; + return encodeGoogleGenAIRequest(finalParams); } function createClient( @@ -61,8 +116,9 @@ async function abortPromise(signal: AbortSignal): Promise { } interface GoogleGenAITransport { - readonly trait: ProtocolTrait | undefined; + readonly connection: GoogleGenAIRequesterOptions['connection']; readonly ctx: TraitContext; + readonly format: ReturnType; readonly resolveClient: (request: LlmClientContext) => GenAIClient; readonly signal: AbortSignal; readonly onEvent?: (event: LlmRequestEvent) => void; @@ -72,11 +128,11 @@ async function internalGenerate( request: GoogleGenAIRequestParams, transport: GoogleGenAITransport, ): Promise { - const { trait, ctx, resolveClient, signal, onEvent } = transport; + const { connection, ctx, format, resolveClient, signal, onEvent } = transport; const client = resolveClient({ model: ctx.model, headers: mergeRequestHeaders( - mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + mergeRequestHeaders(connection?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), request.headers, ), }); @@ -90,7 +146,7 @@ async function internalGenerate( models.generateContentStream(request.params), abortPromise(signal), ]); - const parse = googleGenAIFormat.createStreamParser({ trait, ctx }); + const parse = format.createStreamParser(); let messageId: string | undefined; for await (const chunk of stream) { if (signal.aborted) { @@ -118,10 +174,11 @@ async function internalGenerate( onEvent?.({ type: 'llm.done' }); } -export function createGoogleGenAIRequester( - trait?: ProtocolTrait, - options?: GoogleGenAIBaseOptions, -): LlmRequester { +export function createGoogleGenAIRequester(options?: GoogleGenAIRequesterOptions): LlmRequester { + const connection = options?.connection; + const trait = options?.trait; + const convertError = options?.convertError; + const format = createGoogleGenAIFormat(); const vertexai = options?.vertexai === true; const resolveClient = options?.clientFactory ?? @@ -133,49 +190,54 @@ export function createGoogleGenAIRequester( content: LlmRequestContent, control: LlmRequestControl, ): Promise { - const model = resolveModelConnection(config.model, trait); - const { systemPrompt, tools = [] } = config; + const model = resolveModelConnection(config.model, connection); + const { tools = [] } = config; const { messages } = content; const { signal, onEvent } = control; const ctx: TraitContext = { model }; let request: GoogleGenAIRequestParams; try { - request = googleGenAIFormat.formatRequest({ - model, - messages, - systemPrompt, - tools, - trait, - ctx, - cacheKey: config.cacheKey, - thinking: config.thinking, - responseFormat: config.responseFormat, - maxCompletionTokens: config.maxCompletionTokens, - usedContextTokens: content.usedContextTokens, - maxContextTokens: config.maxContextTokens, - extraParams: config.extraParams, - }); + request = planGoogleGenAIRequest( + { + ...config, + model, + messages, + tools, + usedContextTokens: content.usedContextTokens, + }, + { trait }, + ); } catch (error) { onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); return; } try { - await internalGenerate(request, { trait, ctx, resolveClient, signal, onEvent }); + await internalGenerate(request, { + connection, + ctx, + format, + resolveClient, + signal, + onEvent, + }); } catch (error) { onEvent?.({ type: 'llm.failed.remote', - error: convertGoogleGenAIError(error, (e) => trait?.convertError?.(e, ctx)), + error: convertGoogleGenAIError(error, (e) => convertError?.(e)), }); } }, }; } -export function createGoogleGenAIBase(options?: GoogleGenAIBaseOptions): ProtocolBase { +export function createGoogleGenAIBase( + options?: Pick, +): ProtocolBase { return { capability: getGoogleGenAIModelCapability, - createRequester: (trait?: ProtocolTrait) => createGoogleGenAIRequester(trait, options), + createRequester: (requesterOptions) => + createGoogleGenAIRequester({ ...options, ...requesterOptions }), }; } -export const googleGenAIBase: ProtocolBase = createGoogleGenAIBase(); +export const googleGenAIBase: ProtocolBase = createGoogleGenAIBase(); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/trait.ts new file mode 100644 index 00000000000..7b1a5a8b742 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/trait.ts @@ -0,0 +1,26 @@ +import type { ToolDescription } from '#/llm/message'; +import type { TraitContext } from '#/llm/protocol/base'; +import type { ThinkingStrategy } from '#/llm/protocol/thinking'; + +import type { GoogleContent } from './contract'; + +export interface GoogleGenAITrait { + readonly thinking?: ThinkingStrategy; + + maxCompletionTokens?( + maxCompletionTokens: number, + ctx: TraitContext, + ): Record | undefined; + + convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; + + mergeHistory?( + contents: readonly GoogleContent[], + ctx: TraitContext, + ): GoogleContent[] | undefined; + + buildParams?( + params: Record, + ctx: TraitContext, + ): Record | undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/contract.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/contract.ts new file mode 100644 index 00000000000..412074dd5e4 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/contract.ts @@ -0,0 +1,24 @@ +export type ResponsesInputContentItem = + | { type: 'input_text'; text: string } + | { type: 'input_image'; detail?: string; image_url: string } + | { type: 'input_file'; file_data: string; filename: string } + | { type: 'input_file'; file_url: string } + | { type: 'output_text'; text: string; annotations: unknown[] }; + +export type ResponsesInputItem = + | { type: 'message'; role: string; content: ResponsesInputContentItem[] } + | { type: 'function_call'; call_id: string; name: string; arguments: string } + | { type: 'function_call_output'; call_id: string; output: string | ResponsesInputContentItem[] } + | { + type: 'reasoning'; + summary: { type: 'summary_text'; text: string }[]; + encrypted_content?: string; + }; + +export type OpenAIResponsesRawChunk = Record; + +export type OpenAIResponsesRawUsage = { + input_tokens?: number; + output_tokens?: number; + input_tokens_details?: { cached_tokens?: number } | null; +}; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/format.ts index af24c19524b..202ace27387 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/format.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/format.ts @@ -1,21 +1,23 @@ import type OpenAI from 'openai'; -import { assign, shake } from 'radashi'; import type { LlmRemoteErrorMessage } from '#/llm/errors'; import { NO_FINISH, type FinishInfo } from '#/llm/finish-reason'; -import type { FormatRequestInput, ProtocolFormat, StreamParserOptions } from '#/llm/protocol/format'; +import type { + FormatRequestInput, + ProtocolFormat, + StreamParserOptions, +} from '#/llm/protocol/format'; import type { StreamedMessagePart, ToolDescription } from '#/llm/message'; -import { applyThinking } from '#/llm/protocol/trait'; import type { ResponseFormat } from '#/llm/response-format'; -import { encodeReasoningEffortFallback } from '#/llm/thinking'; import type { TokenUsage } from '#/llm/usage'; import { isContextOverflowErrorCode, isOpenAIInsufficientQuotaCode } from '../openai/format'; -import { lowerMessage, type ResponsesInputItem } from './lower'; +import type { ResponsesInputItem } from './contract'; +import { lowerMessage } from './lower'; type RawObject = Record; -function responseFormatToResponsesText(format: ResponseFormat): RawObject { +export function responseFormatToResponsesText(format: ResponseFormat): RawObject { if (format.type === 'json_object') { return { type: 'json_object' }; } @@ -28,8 +30,6 @@ function responseFormatToResponsesText(format: ResponseFormat): RawObject { }; } -export type { ResponsesInputContentItem, ResponsesInputItem } from './lower'; - type ResponseOutputItemView = | { type: 'message'; @@ -315,7 +315,7 @@ function normalizeResponsesFinish( return NO_FINISH; } -function defaultConvertTool(tool: ToolDescription): Record { +export function defaultOpenAIResponsesTool(tool: ToolDescription): Record { return { type: 'function', name: tool.name, @@ -325,7 +325,40 @@ function defaultConvertTool(tool: ToolDescription): Record { }; } -function parseResponsesUsage(usage: RawObject | null | undefined): TokenUsage | undefined { +export function encodeOpenAIResponsesCacheKey(cacheKey: string): Record { + return { prompt_cache_key: cacheKey }; +} + +export function encodeOpenAIResponsesMaxCompletionTokens(cap: number): Record { + return { max_output_tokens: cap }; +} + +export function applyOpenAIResponsesResponseFormat( + kwargs: Record, + format: ResponseFormat, +): Record { + return { + ...kwargs, + text: { ...asRawObject(kwargs['text']), format: responseFormatToResponsesText(format) }, + }; +} + +export function normalizeOpenAIResponsesReasoning( + kwargs: Record, +): Record { + const reasoningEffort = kwargs['reasoning_effort'] as string | undefined; + if (reasoningEffort === undefined) { + return kwargs; + } + const { reasoning_effort: _dropped, ...rest } = kwargs; + return { + ...rest, + reasoning: { effort: reasoningEffort, summary: 'auto' }, + include: ['reasoning.encrypted_content'], + }; +} + +export function parseOpenAIResponsesUsage(usage: RawObject | null | undefined): TokenUsage | undefined { if (usage === null || usage === undefined) { return undefined; } @@ -351,319 +384,277 @@ function extractEventUsage(event: RawObject): RawObject | undefined { return readObjectField(event, 'usage'); } -function resolveRequestKwargs(input: FormatRequestInput): Record { - const { - trait, - ctx, - cacheKey, - thinking, - responseFormat, - maxCompletionTokens, - usedContextTokens, - maxContextTokens, - extraParams, - } = input; - let kwargs: Record = {}; - if (cacheKey !== undefined) { - kwargs = trait?.cacheKey?.(cacheKey, ctx) ?? { prompt_cache_key: cacheKey }; - } - if (thinking !== undefined) { - kwargs = applyThinking(kwargs, thinking, trait, ctx, (t) => - encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true), - ).kwargs; - } - if (maxCompletionTokens !== undefined) { - let cap = maxCompletionTokens; - if ( - usedContextTokens !== undefined && - maxContextTokens !== undefined && - maxContextTokens > 0 - ) { - cap = Math.min(cap, maxContextTokens - usedContextTokens); - } - cap = Math.max(1, cap); - const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - kwargs = { ...kwargs, max_output_tokens: cap }; - } - } - if (responseFormat !== undefined) { - kwargs['text'] = { - ...asRawObject(kwargs['text']), - format: responseFormatToResponsesText(responseFormat), - }; - } - const reasoningEffort = kwargs['reasoning_effort'] as string | undefined; - delete kwargs['reasoning_effort']; - if (reasoningEffort !== undefined) { - kwargs['reasoning'] = { effort: reasoningEffort, summary: 'auto' }; - kwargs['include'] = ['reasoning.encrypted_content']; - } - kwargs = assign(kwargs, extraParams?.responses ?? {}); - kwargs = shake(kwargs); - return kwargs; -} - export interface OpenAIResponsesRequestParams { readonly params: OpenAI.Responses.ResponseCreateParamsStreaming; readonly headers?: Record; } -function sessionHeadersForRequest(input: FormatRequestInput): Record | undefined { +export interface OpenAIResponsesLowerOptions { + readonly extractText: boolean; +} + +export function lowerOpenAIResponsesRequest( + input: FormatRequestInput, + options: OpenAIResponsesLowerOptions, +): ResponsesInputItem[] { + return input.messages.flatMap((message) => + lowerMessage(message, { modelName: input.model.model, extractText: options.extractText }), + ); +} + +export interface OpenAIResponsesRequestParts { + readonly input: readonly ResponsesInputItem[]; + readonly tools: readonly Record[]; + readonly kwargs: Readonly>; +} + +export function assembleOpenAIResponsesRequest( + input: FormatRequestInput, + parts: OpenAIResponsesRequestParts, +): Record { + return { + model: input.model.model, + instructions: input.systemPrompt ? input.systemPrompt : undefined, + input: parts.input, + tools: parts.tools.length === 0 ? undefined : parts.tools, + store: false, + stream: true, + ...parts.kwargs, + }; +} + +export function encodeOpenAIResponsesRequest( + params: Record, +): OpenAIResponsesRequestParams { + return { params: params as unknown as OpenAI.Responses.ResponseCreateParamsStreaming }; +} + +export function sessionHeadersForRequest( + input: FormatRequestInput, +): Record | undefined { const { cacheKey } = input; if (cacheKey === undefined) return undefined; return { 'x-opencode-session': cacheKey }; } -export const openAIResponsesFormat: ProtocolFormat = { - formatRequest(input) { - const { messages, systemPrompt, tools, trait, ctx } = input; - const kwargs = resolveRequestKwargs(input); - const inputItems = messages.flatMap((message) => - lowerMessage(message, { - modelName: ctx.model.model, - extractText: trait?.toolMessageConversion?.(ctx) === 'extract_text', - }), - ); - const finalInput = - (trait?.mergeHistory?.(inputItems, ctx) as ResponsesInputItem[] | undefined) ?? inputItems; - const createParams: Record = { - model: ctx.model.model, - instructions: systemPrompt ? systemPrompt : undefined, - input: finalInput, - tools: - tools.length === 0 - ? undefined - : tools.map((tool) => trait?.convertTool?.(tool, ctx) ?? defaultConvertTool(tool)), - store: false, - stream: true, - ...kwargs, - }; - const finalParams = trait?.buildParams?.(createParams, ctx) ?? createParams; - const headers = sessionHeadersForRequest(input); - return { - params: finalParams as unknown as OpenAI.Responses.ResponseCreateParamsStreaming, - ...(headers !== undefined ? { headers } : {}), - }; - }, - - createStreamParser(options?: StreamParserOptions) { - const functionCallArgumentsByIndex = new Map(); - let unindexedFunctionCallArguments: string | undefined; - - const hasFunctionCallArguments = (streamIndex: number | string | undefined): boolean => - streamIndex === undefined - ? unindexedFunctionCallArguments !== undefined - : functionCallArgumentsByIndex.has(streamIndex); - - const getFunctionCallArguments = (streamIndex: number | string | undefined): string => - streamIndex === undefined - ? (unindexedFunctionCallArguments as string) - : functionCallArgumentsByIndex.get(streamIndex)!; - - const setFunctionCallArguments = ( - streamIndex: number | string | undefined, - argumentsValue: string, - ): void => { - if (streamIndex === undefined) { - unindexedFunctionCallArguments = argumentsValue; - } else { - functionCallArgumentsByIndex.set(streamIndex, argumentsValue); - } - }; +export function createOpenAIResponsesFormat(): ProtocolFormat { + return { + createStreamParser(options?: StreamParserOptions) { + const functionCallArgumentsByIndex = new Map(); + let unindexedFunctionCallArguments: string | undefined; + + const hasFunctionCallArguments = (streamIndex: number | string | undefined): boolean => + streamIndex === undefined + ? unindexedFunctionCallArguments !== undefined + : functionCallArgumentsByIndex.has(streamIndex); + + const getFunctionCallArguments = (streamIndex: number | string | undefined): string => + streamIndex === undefined + ? (unindexedFunctionCallArguments as string) + : functionCallArgumentsByIndex.get(streamIndex)!; + + const setFunctionCallArguments = ( + streamIndex: number | string | undefined, + argumentsValue: string, + ): void => { + if (streamIndex === undefined) { + unindexedFunctionCallArguments = argumentsValue; + } else { + functionCallArgumentsByIndex.set(streamIndex, argumentsValue); + } + }; + + const appendFunctionCallArguments = ( + streamIndex: number | string | undefined, + argumentsPart: string, + context: string, + ): void => { + if (!hasFunctionCallArguments(streamIndex)) { + failResponsesDecode( + context, + `received function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, + ); + } + setFunctionCallArguments(streamIndex, getFunctionCallArguments(streamIndex) + argumentsPart); + }; + + const finalArgumentsSuffix = ( + streamIndex: number | string | undefined, + finalArguments: string, + context: string, + ): StreamedMessagePart[] => { + if (!hasFunctionCallArguments(streamIndex)) { + failResponsesDecode( + context, + `received final function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, + ); + } - const appendFunctionCallArguments = ( - streamIndex: number | string | undefined, - argumentsPart: string, - context: string, - ): void => { - if (!hasFunctionCallArguments(streamIndex)) { - failResponsesDecode( - context, - `received function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, - ); - } - setFunctionCallArguments(streamIndex, getFunctionCallArguments(streamIndex) + argumentsPart); - }; + const accumulatedArguments = getFunctionCallArguments(streamIndex); + if (finalArguments === accumulatedArguments) { + return []; + } - const finalArgumentsSuffix = ( - streamIndex: number | string | undefined, - finalArguments: string, - context: string, - ): StreamedMessagePart[] => { - if (!hasFunctionCallArguments(streamIndex)) { - failResponsesDecode( - context, - `received final function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, - ); - } - - const accumulatedArguments = getFunctionCallArguments(streamIndex); - if (finalArguments === accumulatedArguments) { - return []; - } - - if (!finalArguments.startsWith(accumulatedArguments)) { - throw new Error( - `OpenAI Responses final function-call arguments for stream index ${formatResponseStreamIndex( - streamIndex, - )} do not match the streamed argument deltas.`, - ); - } - - const suffix = finalArguments.slice(accumulatedArguments.length); - setFunctionCallArguments(streamIndex, finalArguments); - if (suffix.length === 0) { - return []; - } - - return [{ type: 'tool_call_part', argumentsPart: suffix, index: streamIndex }]; - }; + if (!finalArguments.startsWith(accumulatedArguments)) { + throw new Error( + `OpenAI Responses final function-call arguments for stream index ${formatResponseStreamIndex( + streamIndex, + )} do not match the streamed argument deltas.`, + ); + } - return (chunk, sink) => { - const event = asRawObject(chunk); - if (event === null) { - return; - } - const hookedUsage = - options?.trait?.extractUsage !== undefined && options.ctx !== undefined - ? options.trait.extractUsage(event, options.ctx) - : undefined; - const usage = parseResponsesUsage( - hookedUsage !== undefined ? hookedUsage : extractEventUsage(event), - ); - if (usage !== undefined) { - sink.onUsage?.(usage); - } - const type = readStringField(event, 'type'); - if (type === undefined) { - if (!hasOwn(event, 'type')) { - const message = readStringField(event, 'message'); - if (message !== undefined) { - sink.onError?.(malformedStreamErrorEvent(message)); - return; - } + const suffix = finalArguments.slice(accumulatedArguments.length); + setFunctionCallArguments(streamIndex, finalArguments); + if (suffix.length === 0) { + return []; } - failResponsesDecode('stream event.type', 'must be a string.'); - } - switch (type) { - case 'response.output_text.delta': - sink.onDelta({ type: 'text', text: requireStringField(event, 'delta', type) }); + return [{ type: 'tool_call_part', argumentsPart: suffix, index: streamIndex }]; + }; + + return (chunk, sink) => { + const event = asRawObject(chunk); + if (event === null) { return; - case 'response.output_item.added': { - const item = readResponseOutputItem(event['item'], `${type}.item`); - const outputIndex = readNumberField(event, 'output_index'); - if (item.type !== 'function_call') { - return; + } + const defaultUsage = parseOpenAIResponsesUsage(extractEventUsage(event)); + const usage = + options?.resolveUsage === undefined + ? defaultUsage + : options.resolveUsage(event, defaultUsage); + if (usage !== undefined) { + sink.onUsage?.(usage); + } + const type = readStringField(event, 'type'); + if (type === undefined) { + if (!hasOwn(event, 'type')) { + const message = readStringField(event, 'message'); + if (message !== undefined) { + sink.onError?.(malformedStreamErrorEvent(message)); + return; + } } - const streamIndex = responseStreamIndex(item.itemId, outputIndex); - setFunctionCallArguments(streamIndex, item.arguments ?? ''); - sink.onDelta({ - type: 'function', - id: functionCallId(item.callId), - name: requireFunctionCallName(item), - arguments: item.arguments ?? null, - _streamIndex: streamIndex, - }); - return; + failResponsesDecode('stream event.type', 'must be a string.'); } - case 'response.output_item.done': { - const item = readResponseOutputItem(event['item'], `${type}.item`); - const outputIndex = readNumberField(event, 'output_index'); - if (item.type === 'reasoning') { - sink.onDelta({ type: 'think', think: '', encrypted: item.encryptedContent }); + + switch (type) { + case 'response.output_text.delta': + sink.onDelta({ type: 'text', text: requireStringField(event, 'delta', type) }); return; - } - if (item.type === 'function_call' && typeof item.arguments === 'string') { + case 'response.output_item.added': { + const item = readResponseOutputItem(event['item'], `${type}.item`); + const outputIndex = readNumberField(event, 'output_index'); + if (item.type !== 'function_call') { + return; + } const streamIndex = responseStreamIndex(item.itemId, outputIndex); - for (const part of finalArgumentsSuffix(streamIndex, item.arguments, type)) { - sink.onDelta(part); + setFunctionCallArguments(streamIndex, item.arguments ?? ''); + sink.onDelta({ + type: 'function', + id: functionCallId(item.callId), + name: requireFunctionCallName(item), + arguments: item.arguments ?? null, + _streamIndex: streamIndex, + }); + return; + } + case 'response.output_item.done': { + const item = readResponseOutputItem(event['item'], `${type}.item`); + const outputIndex = readNumberField(event, 'output_index'); + if (item.type === 'reasoning') { + sink.onDelta({ type: 'think', think: '', encrypted: item.encryptedContent }); + return; + } + if (item.type === 'function_call' && typeof item.arguments === 'string') { + const streamIndex = responseStreamIndex(item.itemId, outputIndex); + for (const part of finalArgumentsSuffix(streamIndex, item.arguments, type)) { + sink.onDelta(part); + } } + return; } - return; - } - case 'response.function_call_arguments.delta': { - const streamIndex = responseStreamIndex( - readStringField(event, 'item_id'), - readNumberField(event, 'output_index'), - ); - const argumentsPart = requireStringField(event, 'delta', type); - appendFunctionCallArguments(streamIndex, argumentsPart, type); - sink.onDelta({ type: 'tool_call_part', argumentsPart, index: streamIndex }); - return; - } - case 'response.function_call_arguments.done': { - const functionArguments = requireStringField(event, 'arguments', type); - const streamIndex = responseStreamIndex( - readStringField(event, 'item_id'), - readNumberField(event, 'output_index'), - ); - for (const part of finalArgumentsSuffix(streamIndex, functionArguments, type)) { - sink.onDelta(part); + case 'response.function_call_arguments.delta': { + const streamIndex = responseStreamIndex( + readStringField(event, 'item_id'), + readNumberField(event, 'output_index'), + ); + const argumentsPart = requireStringField(event, 'delta', type); + appendFunctionCallArguments(streamIndex, argumentsPart, type); + sink.onDelta({ type: 'tool_call_part', argumentsPart, index: streamIndex }); + return; } - return; - } - case 'response.reasoning_summary_part.added': - sink.onDelta({ type: 'think', think: '' }); - return; - case 'response.reasoning_summary_text.delta': - sink.onDelta({ type: 'think', think: requireStringField(event, 'delta', type) }); - return; - case 'response.completed': - case 'response.incomplete': { - const response = readObjectField(event, 'response'); - const messageId = response === undefined ? undefined : readStringField(response, 'id'); - if (messageId !== undefined) { - sink.onMessageId?.(messageId); + case 'response.function_call_arguments.done': { + const functionArguments = requireStringField(event, 'arguments', type); + const streamIndex = responseStreamIndex( + readStringField(event, 'item_id'), + readNumberField(event, 'output_index'), + ); + for (const part of finalArgumentsSuffix(streamIndex, functionArguments, type)) { + sink.onDelta(part); + } + return; } - const status = response === undefined ? undefined : readStringField(response, 'status'); - const incompleteDetails = - response === undefined ? undefined : readObjectField(response, 'incomplete_details'); - const reason = - incompleteDetails === undefined - ? undefined - : readStringField(incompleteDetails, 'reason'); - sink.onFinish(normalizeResponsesFinish(status ?? type.slice('response.'.length), reason)); - return; - } - case 'error': { - const message = requireStringField(event, 'message', type); - sink.onError?.( - errorFromOpenAIResponsesEvent( - 'OpenAI Responses stream error', - readNullableStringField(event, 'code') ?? null, - message, - readNullableStringField(event, 'param') ?? null, - ), - ); - return; - } - case 'response.failed': { - const response = requireObjectField(event, 'response', type); - const error = readResponsesFailedResponseError(response); - if (error !== undefined) { + case 'response.reasoning_summary_part.added': + sink.onDelta({ type: 'think', think: '' }); + return; + case 'response.reasoning_summary_text.delta': + sink.onDelta({ type: 'think', think: requireStringField(event, 'delta', type) }); + return; + case 'response.completed': + case 'response.incomplete': { + const response = readObjectField(event, 'response'); + const messageId = response === undefined ? undefined : readStringField(response, 'id'); + if (messageId !== undefined) { + sink.onMessageId?.(messageId); + } + const status = response === undefined ? undefined : readStringField(response, 'status'); + const incompleteDetails = + response === undefined ? undefined : readObjectField(response, 'incomplete_details'); + const reason = + incompleteDetails === undefined + ? undefined + : readStringField(incompleteDetails, 'reason'); + sink.onFinish( + normalizeResponsesFinish(status ?? type.slice('response.'.length), reason), + ); + return; + } + case 'error': { + const message = requireStringField(event, 'message', type); sink.onError?.( errorFromOpenAIResponsesEvent( - 'OpenAI Responses response.failed', - error.code, - error.message, - null, + 'OpenAI Responses stream error', + readNullableStringField(event, 'code') ?? null, + message, + readNullableStringField(event, 'param') ?? null, ), ); return; } - sink.onError?.({ - kind: 'provider', - message: `OpenAI Responses response.failed: ${formatResponsesFailedResponse(response)}`, - }); - return; + case 'response.failed': { + const response = requireObjectField(event, 'response', type); + const error = readResponsesFailedResponseError(response); + if (error !== undefined) { + sink.onError?.( + errorFromOpenAIResponsesEvent( + 'OpenAI Responses response.failed', + error.code, + error.message, + null, + ), + ); + return; + } + sink.onError?.({ + kind: 'provider', + message: `OpenAI Responses response.failed: ${formatResponsesFailedResponse(response)}`, + }); + return; + } + default: + return; } - default: - return; - } - }; - }, -}; + }; + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/lower.ts index 0ace444ad02..8201afaa959 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/lower.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/lower.ts @@ -1,24 +1,8 @@ import type { ContentPart, Message } from '#/llm/message'; +import type { ResponsesInputContentItem, ResponsesInputItem } from './contract'; import { convertToolResultToPlainText } from '../tool-result-text'; -export type ResponsesInputContentItem = - | { type: 'input_text'; text: string } - | { type: 'input_image'; detail?: string; image_url: string } - | { type: 'input_file'; file_data: string; filename: string } - | { type: 'input_file'; file_url: string } - | { type: 'output_text'; text: string; annotations: unknown[] }; - -export type ResponsesInputItem = - | { type: 'message'; role: string; content: ResponsesInputContentItem[] } - | { type: 'function_call'; call_id: string; name: string; arguments: string } - | { type: 'function_call_output'; call_id: string; output: string | ResponsesInputContentItem[] } - | { - type: 'reasoning'; - summary: { type: 'summary_text'; text: string }[]; - encrypted_content?: string; - }; - const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: unsupported audio format)'; const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts index 872bcfa2cbd..79891dd923c 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts @@ -1,11 +1,15 @@ import OpenAI from 'openai'; +import { assign, shake } from 'radashi'; import { ProxyAgent, type Dispatcher } from 'undici'; import { headersToRecord } from '#/llm/errors'; import type { LlmModel } from '#/llm/model'; import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; -import type { ProtocolBase } from '#/llm/protocol/base'; -import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import type { ProtocolBase, ProtocolRequesterOptions, TraitContext } from '#/llm/protocol/base'; +import { resolveModelConnection } from '#/llm/protocol/connection'; +import { applyThinking } from '#/llm/protocol/thinking'; +import { resolveMaxCompletionCap, type FormatRequestInput } from '#/llm/protocol/format'; +import { encodeReasoningEffortFallback } from '#/llm/thinking'; import { mergeRequestHeaders, type LlmClientContext, @@ -24,7 +28,22 @@ import { } from '../tool-call-id'; import { convertOpenAIError } from '../openai/format'; import { getOpenAIResponsesModelCapability } from './capability'; -import { openAIResponsesFormat, type OpenAIResponsesRequestParams } from './format'; +import type { OpenAIResponsesRawChunk } from './contract'; +import type { OpenAIResponsesTrait } from './trait'; +import { + applyOpenAIResponsesResponseFormat, + assembleOpenAIResponsesRequest, + createOpenAIResponsesFormat, + defaultOpenAIResponsesTool, + encodeOpenAIResponsesCacheKey, + encodeOpenAIResponsesMaxCompletionTokens, + encodeOpenAIResponsesRequest, + lowerOpenAIResponsesRequest, + normalizeOpenAIResponsesReasoning, + parseOpenAIResponsesUsage, + sessionHeadersForRequest, + type OpenAIResponsesRequestParams, +} from './format'; const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64), @@ -47,9 +66,65 @@ function createClient(model: LlmModel, headers: Record | undefin }); } +export interface OpenAIResponsesRequesterOptions + extends ProtocolRequesterOptions, + LlmRequesterOptions {} + +export interface OpenAIResponsesRequestPlanOptions { + readonly trait?: OpenAIResponsesTrait; +} + +export function planOpenAIResponsesRequest( + input: FormatRequestInput, + options?: OpenAIResponsesRequestPlanOptions, +): OpenAIResponsesRequestParams { + const trait = options?.trait; + const ctx: TraitContext = { model: input.model }; + let kwargs: Record = {}; + if (input.cacheKey !== undefined) { + kwargs = + trait?.cacheKey?.(input.cacheKey, ctx) ?? encodeOpenAIResponsesCacheKey(input.cacheKey); + } + if (input.thinking !== undefined) { + kwargs = applyThinking(kwargs, input.thinking, trait?.thinking, ctx, (t) => + encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true), + ).kwargs; + } + const cap = resolveMaxCompletionCap(input); + if (cap !== undefined) { + kwargs = { + ...kwargs, + ...(trait?.maxCompletionTokens?.(cap, ctx) ?? encodeOpenAIResponsesMaxCompletionTokens(cap)), + }; + } + if (input.responseFormat !== undefined) { + kwargs = applyOpenAIResponsesResponseFormat(kwargs, input.responseFormat); + } + kwargs = normalizeOpenAIResponsesReasoning(kwargs); + kwargs = shake(assign(kwargs, input.extraParams?.responses ?? {})); + + const lowered = lowerOpenAIResponsesRequest(input, { + extractText: + (input.toolMessageConversion ?? trait?.toolMessageConversion) === 'extract_text', + }); + const merged = trait?.mergeHistory?.(lowered, ctx) ?? lowered; + const tools = input.tools.map( + (tool) => trait?.convertTool?.(tool, ctx) ?? defaultOpenAIResponsesTool(tool), + ); + const params = assembleOpenAIResponsesRequest(input, { input: merged, tools, kwargs }); + const finalParams = trait?.buildParams?.(params, ctx) ?? params; + const headers = sessionHeadersForRequest(input); + return { + ...encodeOpenAIResponsesRequest(finalParams), + ...(headers !== undefined ? { headers } : {}), + }; +} + interface OpenAIResponsesTransport { - readonly trait: ProtocolTrait | undefined; + readonly connection: OpenAIResponsesRequesterOptions['connection']; + readonly trait: OpenAIResponsesTrait | undefined; readonly ctx: TraitContext; + readonly format: ReturnType; readonly resolveClient: (request: LlmClientContext) => OpenAI; readonly signal: AbortSignal; readonly onEvent?: (event: LlmRequestEvent) => void; @@ -59,11 +134,11 @@ async function internalGenerate( request: OpenAIResponsesRequestParams, transport: OpenAIResponsesTransport, ): Promise { - const { trait, ctx, resolveClient, signal, onEvent } = transport; + const { connection, trait, ctx, format, resolveClient, signal, onEvent } = transport; const client = resolveClient({ model: ctx.model, headers: mergeRequestHeaders( - mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + mergeRequestHeaders(connection?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), request.headers, ), }); @@ -72,7 +147,15 @@ async function internalGenerate( .create(request.params, { signal }) .withResponse(); onEvent?.({ type: 'llm.streaming.headers', headers: headersToRecord(response.headers) ?? {} }); - const parse = openAIResponsesFormat.createStreamParser({ trait, ctx }); + const parse = format.createStreamParser({ + resolveUsage: + trait?.extractUsage === undefined + ? undefined + : (chunk, defaultUsage) => { + const hooked = trait.extractUsage?.(chunk as OpenAIResponsesRawChunk); + return hooked !== undefined ? parseOpenAIResponsesUsage(hooked) : defaultUsage; + }, + }); let messageId: string | undefined; for await (const chunk of stream) { let failed = false; @@ -98,9 +181,12 @@ async function internalGenerate( } export function createOpenAIResponsesRequester( - trait?: ProtocolTrait, - options?: LlmRequesterOptions, + options?: OpenAIResponsesRequesterOptions, ): LlmRequester { + const connection = options?.connection; + const trait = options?.trait; + const convertError = options?.convertError; + const format = createOpenAIResponsesFormat(); const resolveClient = options?.clientFactory ?? ((request: LlmClientContext) => createClient(request.model, request.headers)); @@ -110,47 +196,49 @@ export function createOpenAIResponsesRequester( content: LlmRequestContent, control: LlmRequestControl, ): Promise { - const model = resolveModelConnection(config.model, trait); - const { systemPrompt, tools = [] } = config; + const model = resolveModelConnection(config.model, connection); + const { tools = [] } = config; const { messages } = content; const { signal, onEvent } = control; const ctx: TraitContext = { model }; let request: OpenAIResponsesRequestParams; try { - const policy = trait?.toolCallIdPolicy?.(ctx) ?? OPENAI_RESPONSES_TOOL_CALL_ID_POLICY; - request = openAIResponsesFormat.formatRequest({ - model, - messages: normalizeToolCallIdsForProvider(messages, policy), - systemPrompt, - tools, - trait, - ctx, - cacheKey: config.cacheKey, - thinking: config.thinking, - responseFormat: config.responseFormat, - maxCompletionTokens: config.maxCompletionTokens, - usedContextTokens: content.usedContextTokens, - maxContextTokens: config.maxContextTokens, - extraParams: config.extraParams, - toolMessageConversion: config.toolMessageConversion, - }); + const policy = trait?.toolCallIdPolicy ?? OPENAI_RESPONSES_TOOL_CALL_ID_POLICY; + request = planOpenAIResponsesRequest( + { + ...config, + model, + messages: normalizeToolCallIdsForProvider(messages, policy), + tools, + usedContextTokens: content.usedContextTokens, + }, + { trait }, + ); } catch (error) { onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); return; } try { - await internalGenerate(request, { trait, ctx, resolveClient, signal, onEvent }); + await internalGenerate(request, { + connection, + trait, + ctx, + format, + resolveClient, + signal, + onEvent, + }); } catch (error) { onEvent?.({ type: 'llm.failed.remote', - error: convertOpenAIError(error, (e) => trait?.convertError?.(e, ctx)), + error: convertOpenAIError(error, (e) => convertError?.(e)), }); } }, }; } -export const openAIResponsesBase: ProtocolBase = { +export const openAIResponsesBase: ProtocolBase = { capability: getOpenAIResponsesModelCapability, createRequester: createOpenAIResponsesRequester, }; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts new file mode 100644 index 00000000000..b27ea79f534 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts @@ -0,0 +1,35 @@ +import type { ToolDescription } from '#/llm/message'; +import type { TraitContext } from '#/llm/protocol/base'; +import type { ThinkingStrategy } from '#/llm/protocol/thinking'; +import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester'; + +import type { OpenAIResponsesRawChunk, OpenAIResponsesRawUsage, ResponsesInputItem } from './contract'; + +export interface OpenAIResponsesTrait { + readonly toolCallIdPolicy?: ToolCallIdPolicy; + readonly toolMessageConversion?: ToolMessageConversion; + readonly strictThinkingValidation?: boolean; + + readonly thinking?: ThinkingStrategy; + + cacheKey?(key: string, ctx: TraitContext): Record | undefined; + + maxCompletionTokens?( + maxCompletionTokens: number, + ctx: TraitContext, + ): Record | undefined; + + convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; + + mergeHistory?( + messages: readonly ResponsesInputItem[], + ctx: TraitContext, + ): ResponsesInputItem[] | undefined; + + buildParams?( + params: Record, + ctx: TraitContext, + ): Record | undefined; + + extractUsage?(chunk: OpenAIResponsesRawChunk): OpenAIResponsesRawUsage | null | undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/contract.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/contract.ts new file mode 100644 index 00000000000..bbaa5d4475d --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/contract.ts @@ -0,0 +1,48 @@ +export type OpenAIContentPart = { + type: 'text' | 'image_url' | 'audio_url' | 'video_url'; + text?: string | undefined; + image_url?: { url: string; id?: string | null } | undefined; + audio_url?: { url: string; id?: string | null } | undefined; + video_url?: { url: string; id?: string | null } | undefined; +}; + +export type OpenAIWireToolCall = { + id: string; + type: 'function'; + function: { name: string; arguments: string }; +}; + +export type OpenAIWireMessage = + | { role: 'system' | 'user'; content: string | OpenAIContentPart[] } + | { + role: 'assistant'; + content: string | OpenAIContentPart[] | null; + tool_calls?: OpenAIWireToolCall[]; + } + | { role: 'tool'; tool_call_id: string; content: string | OpenAIContentPart[] }; + +export type OpenAIRawUsage = { + prompt_tokens?: number; + completion_tokens?: number; + cached_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number } | null; +}; + +export type OpenAIRawStreamToolCallDelta = { + index?: number | string; + id?: string; + function?: { name?: string; arguments?: string } | null; +}; + +export type OpenAIRawChunk = { + id?: string; + choices?: { + delta?: { + content?: string | null; + reasoning_content?: string | null; + tool_calls?: OpenAIRawStreamToolCallDelta[]; + }; + finish_reason?: string | null; + }[]; + usage?: OpenAIRawUsage | null; +}; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts index 97545836377..58c01d58913 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts @@ -4,7 +4,6 @@ import OpenAI, { APIError as RawOpenAISDKAPIError, OpenAIError as RawOpenAISDKError, } from 'openai'; -import { assign, shake } from 'radashi'; import { headersToRecord, @@ -19,31 +18,32 @@ import { import { NO_FINISH, type FinishInfo, type FinishReason } from '#/llm/finish-reason'; import type { FormatRequestInput, - FormatRequestOptions, ProtocolFormat, + StreamParser, StreamParserOptions, } from '#/llm/protocol/format'; -import { - type StreamedMessagePart, - type ToolDescription, -} from '#/llm/message'; +import { type Message, type StreamedMessagePart, type ToolDescription } from '#/llm/message'; import { toolResultToPlainText } from '#/llm/protocol/patterns'; import { applyPatterns } from '#/llm/protocol/rewrite'; -import { applyThinking } from '#/llm/protocol/trait'; +import type { ToolMessageConversion } from '#/llm/requester/requester'; import type { ResponseFormat } from '#/llm/response-format'; -import { encodeReasoningEffortFallback } from '#/llm/thinking'; import type { TokenUsage } from '#/llm/usage'; -import { lowerMessage, type OpenAIWireMessage } from './lower'; +import type { + OpenAIRawChunk, + OpenAIRawStreamToolCallDelta, + OpenAIRawUsage, + OpenAIWireMessage, +} from './contract'; +import { lowerMessage } from './lower'; import { extractToolMedia } from './patterns'; import { convertReasoningDetails, - DEFAULT_REASONING_KEY, extractReasoning, extractReasoningDetails, } from './reasoning-key'; -function responseFormatToOpenAI(format: ResponseFormat): Record { +export function responseFormatToOpenAI(format: ResponseFormat): Record { if (format.type === 'json_object') { return { type: 'json_object' }; } @@ -58,30 +58,40 @@ function responseFormatToOpenAI(format: ResponseFormat): Record }; } -export type { OpenAIContentPart, OpenAIWireMessage, OpenAIWireToolCall } from './lower'; +export function encodeOpenAICacheKey(cacheKey: string): Record { + return { prompt_cache_key: cacheKey }; +} -type RawUsage = { - prompt_tokens?: number; - completion_tokens?: number; - cached_tokens?: number; - prompt_tokens_details?: { cached_tokens?: number } | null; -}; +export function encodeOpenAIThinkHistoryKwargs(): Record { + return { reasoning_effort: 'medium' }; +} -interface RawToolCall { - id?: string; - function?: { name?: string; arguments?: string } | null; +const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; + +function usesMaxCompletionTokens(model: string): boolean { + const normalized = model.toLowerCase(); + return /^o\d(?:$|[-.])/.test(normalized) || /^gpt-5(?:$|[-.])/.test(normalized); } -interface RawResponseMessage { - content?: string | null; - reasoning_content?: string | null; - tool_calls?: RawToolCall[]; +export function encodeOpenAIMaxCompletionTokens( + model: string, + cap: number, +): Record { + const capped = Math.max(1, Math.min(cap, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING)); + return usesMaxCompletionTokens(model) + ? { max_completion_tokens: capped } + : { max_tokens: capped }; } -interface RawStreamToolCallDelta { - index?: number | string; - id?: string; - function?: { name?: string; arguments?: string } | null; +export function defaultOpenAITool(tool: ToolDescription): Record { + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + }; } interface BufferedStreamToolCall { @@ -90,24 +100,6 @@ interface BufferedStreamToolCall { emitted: boolean; } -type RawChunk = { - id?: string; - choices?: { - delta?: { - content?: string | null; - reasoning_content?: string | null; - tool_calls?: RawStreamToolCallDelta[]; - }; - finish_reason?: string | null; - }[]; - usage?: RawUsage | null; -}; - -type RawResponse = { - choices?: { message?: RawResponseMessage }[]; - usage?: RawUsage | null; -}; - function normalizeFinishReason(raw: string | null | undefined): FinishInfo { if (raw === null || raw === undefined) { return NO_FINISH; @@ -130,7 +122,7 @@ function normalizeFinishReason(raw: string | null | undefined): FinishInfo { return { finishReason, rawFinishReason: raw }; } -function parseRawUsage(usage: RawUsage | null | undefined): TokenUsage | undefined { +export function parseOpenAIUsage(usage: OpenAIRawUsage | null | undefined): TokenUsage | undefined { if (usage === null || usage === undefined) { return undefined; } @@ -145,259 +137,194 @@ function parseRawUsage(usage: RawUsage | null | undefined): TokenUsage | undefin }; } -const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; +export interface OpenAIRequestParams { + readonly params: OpenAI.Chat.ChatCompletionCreateParamsStreaming; + readonly headers?: Record; +} -function usesMaxCompletionTokens(model: string): boolean { - const normalized = model.toLowerCase(); - return /^o\d(?:$|[-.])/.test(normalized) || /^gpt-5(?:$|[-.])/.test(normalized); +export interface OpenAILowerOptions { + readonly reasoningKey: string; + readonly preserveThinking: boolean; + readonly toolMessageConversion: ToolMessageConversion | undefined; } -function completionTokenKwargs( - model: string, - maxCompletionTokens: number, -): Record { - return usesMaxCompletionTokens(model) - ? { max_completion_tokens: maxCompletionTokens } - : { max_tokens: maxCompletionTokens }; +export interface OpenAILoweredMessage { + readonly source: Message; + readonly message: OpenAIWireMessage; } -interface ResolvedRequestKwargs { - kwargs: Record; - preserveThinking: boolean; +export function lowerOpenAIRequest( + input: FormatRequestInput, + options: OpenAILowerOptions, +): OpenAILoweredMessage[] { + const conversion = options.toolMessageConversion; + const mediaPattern = + conversion === 'extract_text' + ? toolResultToPlainText + : conversion === 'keep_parts' + ? undefined + : extractToolMedia; + const normalized = + mediaPattern === undefined ? input.messages : applyPatterns(input.messages, [mediaPattern]); + return normalized.flatMap((message) => + lowerMessage(message, { + reasoningKey: options.reasoningKey, + preserveThinking: options.preserveThinking, + toolMessageConversion: conversion, + }).map((wire) => ({ source: message, message: wire })), + ); } -function resolveRequestKwargs(input: FormatRequestInput): ResolvedRequestKwargs { - const { - messages, - trait, - ctx, - cacheKey, - thinking, - responseFormat, - maxCompletionTokens, - usedContextTokens, - maxContextTokens, - extraParams, - } = input; - let kwargs: Record = {}; - if (cacheKey !== undefined) { - kwargs = trait?.cacheKey?.(cacheKey, ctx) ?? { prompt_cache_key: cacheKey }; - } - let preserveThinking = false; - if (thinking !== undefined) { - const applied = applyThinking(kwargs, thinking, trait, ctx, (t) => - encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true), - ); - kwargs = applied.kwargs; - preserveThinking = applied.preserveThinking; - } - if ( - trait?.withThinking === undefined && - thinking?.effort !== 'off' && - kwargs['reasoning_effort'] === undefined && - messages.some((message) => message.content.some((part) => part.type === 'think')) - ) { - kwargs = { ...kwargs, reasoning_effort: 'medium' }; - } - if (responseFormat !== undefined) { - kwargs = { ...kwargs, response_format: responseFormatToOpenAI(responseFormat) }; - } - if (maxCompletionTokens !== undefined) { - let cap = maxCompletionTokens; - if ( - usedContextTokens !== undefined && - maxContextTokens !== undefined && - maxContextTokens > 0 - ) { - cap = Math.min(cap, maxContextTokens - usedContextTokens); - } - cap = Math.max(1, cap); - const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - const capped = Math.min(cap, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING); - kwargs = { ...kwargs, ...completionTokenKwargs(ctx.model.model, Math.max(1, capped)) }; - } - } - kwargs = assign(kwargs, extraParams?.openai ?? {}); - kwargs = shake(kwargs); - return { kwargs, preserveThinking }; +export interface OpenAIRequestParts { + readonly messages: readonly OpenAIWireMessage[]; + readonly tools: readonly Record[]; + readonly kwargs: Readonly>; } -export interface OpenAIRequestParams { - readonly params: OpenAI.Chat.ChatCompletionCreateParamsStreaming; - readonly headers?: Record; +export function assembleOpenAIRequest( + input: FormatRequestInput, + parts: OpenAIRequestParts, +): Record { + return { + model: input.model.model, + messages: parts.messages, + tools: parts.tools.length === 0 ? undefined : parts.tools, + stream: true, + stream_options: { include_usage: true }, + ...parts.kwargs, + }; +} + +export function encodeOpenAIRequest(params: Record): OpenAIRequestParams { + return { params: params as unknown as OpenAI.Chat.ChatCompletionCreateParamsStreaming }; } -function sessionHeadersForRequest(input: FormatRequestInput): Record | undefined { +export function sessionHeadersForRequest( + input: FormatRequestInput, +): Record | undefined { const { cacheKey } = input; if (cacheKey === undefined) return undefined; return { 'x-opencode-session': cacheKey }; } -export const openAIFormat: ProtocolFormat = { - formatRequest(input, options?: FormatRequestOptions) { - const { messages, systemPrompt, tools, trait, ctx } = input; - const reasoningKey = options?.reasoningKey ?? DEFAULT_REASONING_KEY; - const { kwargs, preserveThinking } = resolveRequestKwargs(input); +export interface OpenAIStreamParserOptions extends StreamParserOptions { + readonly reasoningKey?: string; +} - const conversion = trait?.toolMessageConversion?.(ctx); - const mediaPattern = - conversion === 'extract_text' - ? toolResultToPlainText - : conversion === 'keep_parts' - ? undefined - : extractToolMedia; - const normalized = - mediaPattern === undefined ? messages : applyPatterns(messages, [mediaPattern]); - const converted: OpenAIWireMessage[] = []; - if (systemPrompt) { - converted.push({ role: 'system', content: systemPrompt }); - } - for (const message of normalized) { - converted.push(...lowerMessage(message, { trait, ctx, reasoningKey, preserveThinking })); - } - const finalMessages = - (trait?.mergeHistory?.(converted, ctx) as OpenAIWireMessage[] | undefined) ?? converted; - const createParams: Record = { - model: ctx.model.model, - messages: finalMessages, - tools: - tools.length === 0 - ? undefined - : tools.map((tool) => trait?.convertTool?.(tool, ctx) ?? defaultConvertTool(tool)), - stream: true, - stream_options: { include_usage: true }, - ...kwargs, - }; - const finalParams = trait?.buildParams?.(createParams, ctx) ?? createParams; - const headers = sessionHeadersForRequest(input); - return { - params: finalParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsStreaming, - ...(headers !== undefined ? { headers } : {}), - }; - }, +export interface OpenAIProtocolFormat extends ProtocolFormat { + createStreamParser(options?: OpenAIStreamParserOptions): StreamParser; +} - createStreamParser(options?: StreamParserOptions) { - const explicitReasoningKey = options?.trait?.reasoningKey?.(options.ctx); - const bufferedToolCalls = new Map(); +export function createOpenAIFormat(): OpenAIProtocolFormat { + return { + createStreamParser(options?: OpenAIStreamParserOptions) { + const bufferedToolCalls = new Map(); - function convertStreamToolCall(toolCall: RawStreamToolCallDelta): StreamedMessagePart[] { - if (toolCall.function === undefined || toolCall.function === null) { - return []; - } - const streamIndex = toolCall.index; - const functionName = toolCall.function.name; - const functionArguments = toolCall.function.arguments; - const hasConcreteName = typeof functionName === 'string' && functionName.length > 0; - const hasArguments = typeof functionArguments === 'string' && functionArguments.length > 0; + function convertStreamToolCall( + toolCall: OpenAIRawStreamToolCallDelta, + ): StreamedMessagePart[] { + if (toolCall.function === undefined || toolCall.function === null) { + return []; + } + const streamIndex = toolCall.index; + const functionName = toolCall.function.name; + const functionArguments = toolCall.function.arguments; + const hasConcreteName = typeof functionName === 'string' && functionName.length > 0; + const hasArguments = typeof functionArguments === 'string' && functionArguments.length > 0; + + if (streamIndex === undefined) { + if (hasConcreteName) { + return [ + { + type: 'function', + id: toolCall.id ?? crypto.randomUUID(), + name: functionName, + arguments: functionArguments ?? null, + }, + ]; + } + if (hasArguments) { + return [{ type: 'tool_call_part', argumentsPart: functionArguments }]; + } + return []; + } - if (streamIndex === undefined) { - if (hasConcreteName) { + const buffered = bufferedToolCalls.get(streamIndex) ?? { arguments: '', emitted: false }; + if (toolCall.id !== undefined) { + buffered.id = toolCall.id; + } + if (!buffered.emitted) { + if (!hasConcreteName) { + if (hasArguments) { + buffered.arguments += functionArguments; + } + bufferedToolCalls.set(streamIndex, buffered); + return []; + } + buffered.emitted = true; + const initialArguments = + buffered.arguments.length > 0 + ? buffered.arguments + (functionArguments ?? '') + : (functionArguments ?? null); + buffered.arguments = ''; + bufferedToolCalls.set(streamIndex, buffered); return [ { type: 'function', - id: toolCall.id ?? crypto.randomUUID(), + id: buffered.id ?? toolCall.id ?? crypto.randomUUID(), name: functionName, - arguments: functionArguments ?? null, + arguments: initialArguments, + _streamIndex: streamIndex, }, ]; } - if (hasArguments) { - return [{ type: 'tool_call_part', argumentsPart: functionArguments }]; - } - return []; - } - - const buffered = bufferedToolCalls.get(streamIndex) ?? { arguments: '', emitted: false }; - if (toolCall.id !== undefined) { - buffered.id = toolCall.id; - } - if (!buffered.emitted) { - if (!hasConcreteName) { - if (hasArguments) { - buffered.arguments += functionArguments; - } - bufferedToolCalls.set(streamIndex, buffered); + if (!hasArguments) { return []; } - buffered.emitted = true; - const initialArguments = - buffered.arguments.length > 0 - ? buffered.arguments + (functionArguments ?? '') - : (functionArguments ?? null); - buffered.arguments = ''; - bufferedToolCalls.set(streamIndex, buffered); - return [ - { - type: 'function', - id: buffered.id ?? toolCall.id ?? crypto.randomUUID(), - name: functionName, - arguments: initialArguments, - _streamIndex: streamIndex, - }, - ]; - } - if (!hasArguments) { - return []; + return [{ type: 'tool_call_part', argumentsPart: functionArguments, index: streamIndex }]; } - return [{ type: 'tool_call_part', argumentsPart: functionArguments, index: streamIndex }]; - } - return (chunk, sink) => { - if (typeof chunk.id === 'string' && chunk.id.length > 0) { - sink.onMessageId?.(chunk.id); - } - const hooked = - options?.trait?.extractUsage !== undefined && options.ctx !== undefined - ? options.trait.extractUsage(chunk as Record, options.ctx) - : undefined; - const usage = parseRawUsage( - (hooked !== undefined ? hooked : chunk.usage) as RawUsage | null | undefined, - ); - if (usage !== undefined) { - sink.onUsage?.(usage); - } - const choice = chunk.choices?.[0]; - if (choice?.finish_reason !== undefined && choice.finish_reason !== null) { - sink.onFinish(normalizeFinishReason(choice.finish_reason)); - } - const delta = choice?.delta; - if (!delta) { - return; - } - const reasoningDetails = - explicitReasoningKey === undefined ? extractReasoningDetails(delta) : undefined; - if (reasoningDetails !== undefined) { - for (const part of convertReasoningDetails(reasoningDetails)) { - sink.onDelta(part); + return (chunk, sink) => { + if (typeof chunk.id === 'string' && chunk.id.length > 0) { + sink.onMessageId?.(chunk.id); } - } else { - const reasoning = extractReasoning(delta); - if (reasoning !== undefined) { - sink.onDelta({ type: 'think', think: reasoning.value }); + const defaultUsage = parseOpenAIUsage(chunk.usage); + const usage = + options?.resolveUsage === undefined + ? defaultUsage + : options.resolveUsage(chunk, defaultUsage); + if (usage !== undefined) { + sink.onUsage?.(usage); } - } - if (typeof delta.content === 'string' && delta.content.length > 0) { - sink.onDelta({ type: 'text', text: delta.content }); - } - for (const toolCall of delta.tool_calls ?? []) { - for (const part of convertStreamToolCall(toolCall)) { - sink.onDelta(part); + const choice = chunk.choices?.[0]; + if (choice?.finish_reason !== undefined && choice.finish_reason !== null) { + sink.onFinish(normalizeFinishReason(choice.finish_reason)); } - } - }; - }, -}; - -function defaultConvertTool(tool: ToolDescription): Record { - return { - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, + const delta = choice?.delta; + if (!delta) { + return; + } + const reasoningDetails = + options?.reasoningKey === undefined ? extractReasoningDetails(delta) : undefined; + if (reasoningDetails !== undefined) { + for (const part of convertReasoningDetails(reasoningDetails)) { + sink.onDelta(part); + } + } else { + const reasoning = extractReasoning(delta); + if (reasoning !== undefined) { + sink.onDelta({ type: 'think', think: reasoning.value }); + } + } + if (typeof delta.content === 'string' && delta.content.length > 0) { + sink.onDelta({ type: 'text', text: delta.content }); + } + for (const toolCall of delta.tool_calls ?? []) { + for (const part of convertStreamToolCall(toolCall)) { + sink.onDelta(part); + } + } + }; }, }; } diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts index 4f72dff3109..2fa4d0ce281 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts @@ -1,32 +1,10 @@ import { extractText, type ContentPart, type Message } from '#/llm/message'; -import type { ProtocolTrait, TraitContext } from '#/llm/protocol/trait'; +import type { ToolMessageConversion } from '#/llm/requester/requester'; +import type { OpenAIContentPart, OpenAIWireMessage } from './contract'; import { TOOL_RESULT_MEDIA_PLACEHOLDER } from './patterns'; import { DEFAULT_REASONING_KEY, REASONING_DETAILS_KEY } from './reasoning-key'; -export type OpenAIContentPart = { - type: 'text' | 'image_url' | 'audio_url' | 'video_url'; - text?: string | undefined; - image_url?: { url: string; id?: string | null } | undefined; - audio_url?: { url: string; id?: string | null } | undefined; - video_url?: { url: string; id?: string | null } | undefined; -}; - -export type OpenAIWireToolCall = { - id: string; - type: 'function'; - function: { name: string; arguments: string }; -}; - -export type OpenAIWireMessage = - | { role: 'system' | 'user'; content: string | OpenAIContentPart[] } - | { - role: 'assistant'; - content: string | OpenAIContentPart[] | null; - tool_calls?: OpenAIWireToolCall[]; - } - | { role: 'tool'; tool_call_id: string; content: string | OpenAIContentPart[] }; - const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: not supported by this provider)'; const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; @@ -83,14 +61,13 @@ function convertToolMessageMediaText(message: Message): string { } export interface OpenAILowerContext { - readonly trait: ProtocolTrait | undefined; - readonly ctx: TraitContext; readonly reasoningKey: string; readonly preserveThinking: boolean; + readonly toolMessageConversion: ToolMessageConversion | undefined; } export function lowerMessage(message: Message, lower: OpenAILowerContext): OpenAIWireMessage[] { - const { trait, ctx, reasoningKey, preserveThinking } = lower; + const { reasoningKey, preserveThinking } = lower; let reasoningContent = ''; let hasReasoningPart = false; const nonThinkParts: ContentPart[] = []; @@ -103,7 +80,7 @@ export function lowerMessage(message: Message, lower: OpenAILowerContext): OpenA } } let content: string | OpenAIContentPart[] | undefined; - if (message.role === 'tool' && trait?.toolMessageConversion?.(ctx) !== 'keep_parts') { + if (message.role === 'tool' && lower.toolMessageConversion !== 'keep_parts') { content = message.content.some((part) => part.type !== 'text' && part.type !== 'think') ? convertToolMessageMediaText(message) : extractText(message); @@ -157,9 +134,5 @@ export function lowerMessage(message: Message, lower: OpenAILowerContext): OpenA } else if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { (converted as Record)[reasoningKey] = reasoningContent; } - const hooked = - trait?.convertMessage === undefined - ? converted - : (trait.convertMessage(message, converted, ctx) as OpenAIWireMessage | null); - return hooked === null ? [] : [hooked]; + return [converted]; } diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts index 16b283702e4..9926b31e9d9 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts @@ -1,11 +1,15 @@ import OpenAI from 'openai'; +import { assign, shake } from 'radashi'; import { ProxyAgent, type Dispatcher } from 'undici'; import { headersToRecord } from '#/llm/errors'; import { modelKey, type LlmModel } from '#/llm/model'; import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; -import type { ProtocolBase } from '#/llm/protocol/base'; -import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import type { ProtocolBase, ProtocolRequesterOptions, TraitContext } from '#/llm/protocol/base'; +import { resolveModelConnection } from '#/llm/protocol/connection'; +import { applyThinking } from '#/llm/protocol/thinking'; +import { resolveMaxCompletionCap, type FormatRequestInput } from '#/llm/protocol/format'; +import { encodeReasoningEffortFallback } from '#/llm/thinking'; import { mergeRequestHeaders, type LlmClientContext, @@ -23,8 +27,24 @@ import { sanitizeToolCallId, } from '../tool-call-id'; import { getOpenAILegacyModelCapability } from './capability'; -import { convertOpenAIError, openAIFormat, type OpenAIRequestParams } from './format'; -import { ReasoningKeyDialect } from './reasoning-key'; +import type { OpenAIWireMessage } from './contract'; +import type { OpenAITrait } from './trait'; +import { + assembleOpenAIRequest, + convertOpenAIError, + createOpenAIFormat, + defaultOpenAITool, + encodeOpenAICacheKey, + encodeOpenAIMaxCompletionTokens, + encodeOpenAIRequest, + encodeOpenAIThinkHistoryKwargs, + lowerOpenAIRequest, + parseOpenAIUsage, + responseFormatToOpenAI, + sessionHeadersForRequest, + type OpenAIRequestParams, +} from './format'; +import { DEFAULT_REASONING_KEY, ReasoningKeyDialect } from './reasoning-key'; const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { normalize: (id) => sanitizeToolCallId(id, 64), @@ -47,10 +67,88 @@ function createClient(model: LlmModel, headers: Record | undefin }); } +export interface OpenAIRequesterOptions + extends ProtocolRequesterOptions, + LlmRequesterOptions {} + +export interface OpenAIRequestPlanOptions { + readonly trait?: OpenAITrait; + readonly reasoningKey?: string; +} + +export function planOpenAIRequest( + input: FormatRequestInput, + options?: OpenAIRequestPlanOptions, +): OpenAIRequestParams { + const trait = options?.trait; + const ctx: TraitContext = { model: input.model }; + let kwargs: Record = {}; + if (input.cacheKey !== undefined) { + kwargs = trait?.cacheKey?.(input.cacheKey, ctx) ?? encodeOpenAICacheKey(input.cacheKey); + } + let preserveThinking = false; + if (input.thinking !== undefined) { + const applied = applyThinking(kwargs, input.thinking, trait?.thinking, ctx, (t) => + encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true), + ); + kwargs = applied.kwargs; + preserveThinking = applied.preserveThinking; + } + if ( + trait?.thinking === undefined && + input.thinking?.effort !== 'off' && + kwargs['reasoning_effort'] === undefined && + input.messages.some((message) => message.content.some((part) => part.type === 'think')) + ) { + kwargs = { ...kwargs, ...encodeOpenAIThinkHistoryKwargs() }; + } + if (input.responseFormat !== undefined) { + kwargs = { ...kwargs, response_format: responseFormatToOpenAI(input.responseFormat) }; + } + const cap = resolveMaxCompletionCap(input); + if (cap !== undefined) { + kwargs = { + ...kwargs, + ...(trait?.maxCompletionTokens?.(cap, ctx) ?? + encodeOpenAIMaxCompletionTokens(ctx.model.model, cap)), + }; + } + kwargs = shake(assign(kwargs, input.extraParams?.openai ?? {})); + + const lowered = lowerOpenAIRequest(input, { + reasoningKey: options?.reasoningKey ?? DEFAULT_REASONING_KEY, + preserveThinking, + toolMessageConversion: input.toolMessageConversion ?? trait?.toolMessageConversion, + }); + const converted = lowered.flatMap(({ source, message }) => { + if (trait?.convertMessage === undefined) { + return [message]; + } + const hooked = trait.convertMessage(source, message, ctx); + return hooked === null ? [] : [hooked]; + }); + const history: readonly OpenAIWireMessage[] = input.systemPrompt + ? [{ role: 'system', content: input.systemPrompt }, ...converted] + : converted; + const merged = trait?.mergeHistory?.(history, ctx) ?? history; + const tools = input.tools.map( + (tool) => trait?.convertTool?.(tool, ctx) ?? defaultOpenAITool(tool), + ); + const params = assembleOpenAIRequest(input, { messages: merged, tools, kwargs }); + const finalParams = trait?.buildParams?.(params, ctx) ?? params; + const headers = sessionHeadersForRequest(input); + return { + ...encodeOpenAIRequest(finalParams), + ...(headers !== undefined ? { headers } : {}), + }; +} + interface OpenAITransport { - readonly trait: ProtocolTrait | undefined; + readonly connection: OpenAIRequesterOptions['connection']; + readonly trait: OpenAITrait | undefined; readonly ctx: TraitContext; - readonly dialect: ReasoningKeyDialect; + readonly format: ReturnType; + readonly reasoning: ReasoningKeyDialect; readonly resolveClient: (request: LlmClientContext) => OpenAI; readonly signal: AbortSignal; readonly onEvent?: (event: LlmRequestEvent) => void; @@ -60,11 +158,11 @@ async function internalGenerate( request: OpenAIRequestParams, transport: OpenAITransport, ): Promise { - const { trait, ctx, dialect, resolveClient, signal, onEvent } = transport; + const { connection, trait, ctx, format, reasoning, resolveClient, signal, onEvent } = transport; const client = resolveClient({ model: ctx.model, headers: mergeRequestHeaders( - mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + mergeRequestHeaders(connection?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), request.headers, ), }); @@ -73,10 +171,19 @@ async function internalGenerate( .create(request.params, { signal }) .withResponse(); onEvent?.({ type: 'llm.streaming.headers', headers: headersToRecord(response.headers) ?? {} }); - const parse = openAIFormat.createStreamParser({ trait, ctx }); + const parse = format.createStreamParser({ + reasoningKey: trait?.reasoningKey, + resolveUsage: + trait?.extractUsage === undefined + ? undefined + : (chunk, defaultUsage) => { + const hooked = trait.extractUsage?.(chunk); + return hooked !== undefined ? parseOpenAIUsage(hooked) : defaultUsage; + }, + }); let messageId: string | undefined; for await (const chunk of stream) { - dialect.observe(chunk.choices?.[0]?.delta); + reasoning.observe(chunk.choices?.[0]?.delta); let failed = false; parse(chunk, { onDelta: (part) => onEvent?.({ type: 'llm.streaming.part', part }), @@ -99,22 +206,23 @@ async function internalGenerate( onEvent?.({ type: 'llm.done' }); } -export function createOpenAIRequester( - trait?: ProtocolTrait, - options?: LlmRequesterOptions, -): LlmRequester { +export function createOpenAIRequester(options?: OpenAIRequesterOptions): LlmRequester { + const connection = options?.connection; + const trait = options?.trait; + const convertError = options?.convertError; + const format = createOpenAIFormat(); const resolveClient = options?.clientFactory ?? ((request: LlmClientContext) => createClient(request.model, request.headers)); - const dialects = new Map(); - const dialectFor = (ctx: TraitContext): ReasoningKeyDialect => { + const reasoningByModel = new Map(); + const reasoningFor = (ctx: TraitContext): ReasoningKeyDialect => { const key = modelKey(ctx.model); - let dialect = dialects.get(key); - if (dialect === undefined) { - dialect = new ReasoningKeyDialect(trait?.reasoningKey?.(ctx)); - dialects.set(key, dialect); + let reasoning = reasoningByModel.get(key); + if (reasoning === undefined) { + reasoning = new ReasoningKeyDialect(trait?.reasoningKey); + reasoningByModel.set(key, reasoning); } - return dialect; + return reasoning; }; return { async generate( @@ -122,52 +230,52 @@ export function createOpenAIRequester( content: LlmRequestContent, control: LlmRequestControl, ): Promise { - const model = resolveModelConnection(config.model, trait); - const { systemPrompt, tools = [] } = config; + const model = resolveModelConnection(config.model, connection); + const { tools = [] } = config; const { messages } = content; const { signal, onEvent } = control; const ctx: TraitContext = { model }; - let dialect: ReasoningKeyDialect; + let reasoning: ReasoningKeyDialect; let request: OpenAIRequestParams; try { - dialect = dialectFor(ctx); - const policy = trait?.toolCallIdPolicy?.(ctx) ?? OPENAI_CHAT_TOOL_CALL_ID_POLICY; - request = openAIFormat.formatRequest( + reasoning = reasoningFor(ctx); + const policy = trait?.toolCallIdPolicy ?? OPENAI_CHAT_TOOL_CALL_ID_POLICY; + request = planOpenAIRequest( { + ...config, model, messages: normalizeToolCallIdsForProvider(messages, policy), - systemPrompt, tools, - trait, - ctx, - cacheKey: config.cacheKey, - thinking: config.thinking, - responseFormat: config.responseFormat, - maxCompletionTokens: config.maxCompletionTokens, usedContextTokens: content.usedContextTokens, - maxContextTokens: config.maxContextTokens, - extraParams: config.extraParams, - toolMessageConversion: config.toolMessageConversion, }, - { reasoningKey: dialect.outboundKey() }, + { trait, reasoningKey: reasoning.outboundKey() }, ); } catch (error) { onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); return; } try { - await internalGenerate(request, { trait, ctx, dialect, resolveClient, signal, onEvent }); + await internalGenerate(request, { + connection, + trait, + ctx, + format, + reasoning, + resolveClient, + signal, + onEvent, + }); } catch (error) { onEvent?.({ type: 'llm.failed.remote', - error: convertOpenAIError(error, (e) => trait?.convertError?.(e, ctx)), + error: convertOpenAIError(error, (e) => convertError?.(e)), }); } }, }; } -export const openAIBase: ProtocolBase = { +export const openAIBase: ProtocolBase = { capability: getOpenAILegacyModelCapability, createRequester: createOpenAIRequester, }; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts new file mode 100644 index 00000000000..756d4542d01 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts @@ -0,0 +1,42 @@ +import type { Message, ToolDescription } from '#/llm/message'; +import type { TraitContext } from '#/llm/protocol/base'; +import type { ThinkingStrategy } from '#/llm/protocol/thinking'; +import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester'; + +import type { OpenAIRawChunk, OpenAIRawUsage, OpenAIWireMessage } from './contract'; + +export interface OpenAITrait { + readonly reasoningKey?: string; + readonly toolCallIdPolicy?: ToolCallIdPolicy; + readonly toolMessageConversion?: ToolMessageConversion; + readonly strictThinkingValidation?: boolean; + + readonly thinking?: ThinkingStrategy; + + cacheKey?(key: string, ctx: TraitContext): Record | undefined; + + maxCompletionTokens?( + maxCompletionTokens: number, + ctx: TraitContext, + ): Record | undefined; + + convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; + + convertMessage?( + message: Message, + converted: OpenAIWireMessage, + ctx: TraitContext, + ): OpenAIWireMessage | null; + + mergeHistory?( + messages: readonly OpenAIWireMessage[], + ctx: TraitContext, + ): OpenAIWireMessage[] | undefined; + + buildParams?( + params: Record, + ctx: TraitContext, + ): Record | undefined; + + extractUsage?(chunk: OpenAIRawChunk): OpenAIRawUsage | null | undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/machine.ts b/packages/agent-core-v2/src/human/llm/requester/machine.ts deleted file mode 100644 index b14af143a0f..00000000000 --- a/packages/agent-core-v2/src/human/llm/requester/machine.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { assign, emit, fromCallback, setup } from '#/xstate2'; - -import type { LlmErrorMessage } from '#/llm/errors'; -import type { Message } from '#/llm/message'; -import type { LlmModel } from '#/llm/model'; - -import type { - LlmRequestConfig, - LlmRequestContent, - LlmRequester, - LlmRequestEvent, -} from './requester'; -import type { LlmRecoveryRecord } from './recovery'; - -export interface LlmInput { - readonly config: LlmRequestConfig; - readonly content: LlmRequestContent; - readonly signal: AbortSignal; -} - -export interface MessageResolveContext { - readonly model: LlmModel; - readonly signal: AbortSignal; -} - -export interface MessageResolver { - readonly id: string; - resolve( - messages: readonly Message[], - ctx: MessageResolveContext, - ): Promise; -} - -export type LlmEvent = - | Exclude - | { type: 'llm.sent'; recovery?: LlmRecoveryRecord } - | { - type: 'llm.retrying'; - failedAttempt: number; - nextAttempt: number; - maxAttempts: number; - delayMs: number; - errorName: string; - errorMessage: string; - statusCode?: number; - } - | { - type: 'llm.recovering'; - strategy: string; - action: string; - errorName: string; - errorMessage: string; - statusCode?: number; - }; - -export type LlmOutput = { type: 'succeeded' } | { type: 'failed'; error: LlmErrorMessage }; - -export interface LlmMachineContext { - input: LlmInput; - outcome?: 'succeeded' | 'failed'; - error?: LlmErrorMessage; -} - -function createRequestActor( - requester: LlmRequester, - messageResolvers: readonly MessageResolver[], -) { - return fromCallback(({ input, sendBack }) => { - void (async () => { - let messages = input.content.messages; - for (const resolver of messageResolvers) { - messages = await resolver.resolve(messages, { - model: input.config.model, - signal: input.signal, - }); - } - await requester.generate( - input.config, - { ...input.content, messages }, - { - signal: input.signal, - onEvent: sendBack, - }, - ); - })(); - }); -} - -export interface CreateLlmMachineOptions { - requester: LlmRequester; - messageResolvers?: readonly MessageResolver[]; -} - -export function createLlmMachine(options: CreateLlmMachineOptions) { - const requestActor = createRequestActor(options.requester, options.messageResolvers ?? []); - return setup({ - types: { - input: {} as LlmInput, - context: {} as LlmMachineContext, - events: {} as LlmEvent, - emitted: {} as LlmEvent, - output: {} as LlmOutput, - }, - actors: { requestActor }, - actions: { - forwardToParent: ({ self, event }) => { - self._parent?.send(event); - }, - sendToParent: ({ self }, params: LlmEvent) => { - self._parent?.send(params); - }, - }, - }).createMachine({ - id: 'llm', - initial: 'generating', - context: ({ input }) => ({ input }), - states: { - generating: { - invoke: { - src: 'requestActor', - input: ({ context }) => context.input, - }, - on: { - 'llm.sent': { - actions: [ - emit({ type: 'llm.sent' as const }), - { type: 'sendToParent', params: { type: 'llm.sent' as const } }, - ], - }, - 'llm.streaming.headers': { - actions: [ - emit(({ event }) => ({ type: 'llm.streaming.headers' as const, headers: event.headers })), - 'forwardToParent', - ], - }, - 'llm.streaming.part': { - actions: [ - emit(({ event }) => ({ type: 'llm.streaming.part' as const, part: event.part })), - 'forwardToParent', - ], - }, - 'llm.streaming.usage': { - actions: [ - emit(({ event }) => ({ type: 'llm.streaming.usage' as const, usage: event.usage })), - 'forwardToParent', - ], - }, - 'llm.streaming.finish': { - actions: [ - emit(({ event }) => ({ type: 'llm.streaming.finish' as const, finish: event.finish })), - 'forwardToParent', - ], - }, - 'llm.streaming.message_id': { - actions: [ - emit(({ event }) => ({ type: 'llm.streaming.message_id' as const, messageId: event.messageId })), - 'forwardToParent', - ], - }, - 'llm.done': { - target: 'succeeded', - actions: [ - assign({ outcome: 'succeeded' as const }), - emit({ type: 'llm.done' as const }), - 'forwardToParent', - ], - }, - 'llm.failed.syntax': { - target: 'failed', - actions: [ - assign({ outcome: 'failed' as const, error: ({ event }) => event.error }), - emit(({ event }) => ({ type: 'llm.failed.syntax' as const, error: event.error })), - 'forwardToParent', - ], - }, - 'llm.failed.remote': { - target: 'failed', - actions: [ - assign({ outcome: 'failed' as const, error: ({ event }) => event.error }), - emit(({ event }) => ({ type: 'llm.failed.remote' as const, error: event.error })), - 'forwardToParent', - ], - }, - }, - }, - succeeded: { type: 'final' }, - failed: { type: 'final' }, - }, - output: ({ context }): LlmOutput => - context.outcome === 'failed' - ? { type: 'failed', error: context.error as LlmErrorMessage } - : { type: 'succeeded' }, - }); -} diff --git a/packages/agent-core-v2/src/human/llm/requester/recovery.ts b/packages/agent-core-v2/src/human/llm/requester/recovery.ts index e65a19dfbd2..0d930a5b2d3 100644 --- a/packages/agent-core-v2/src/human/llm/requester/recovery.ts +++ b/packages/agent-core-v2/src/human/llm/requester/recovery.ts @@ -1,5 +1,6 @@ import type { LlmRemoteErrorMessage } from '#/llm/errors'; import type { Message } from '#/llm/message'; +import type { LlmCredentialProvider } from '#/llm/requester/requester'; export interface LlmRecoveryRecord { readonly strategy: string; @@ -10,11 +11,13 @@ export interface LlmRecoveryContext { readonly error: LlmRemoteErrorMessage; readonly messages: readonly Message[]; readonly applied: readonly LlmRecoveryRecord[]; + readonly credentials?: LlmCredentialProvider; } export interface LlmRecoveryProposal { readonly action: string; - readonly messages: readonly Message[]; + readonly messages?: readonly Message[]; + readonly refreshCredentials?: boolean; } export interface LlmRecovery { diff --git a/packages/agent-core-v2/src/human/llm/requester/requester.ts b/packages/agent-core-v2/src/human/llm/requester/requester.ts index 31129d7b235..185e4abf9b2 100644 --- a/packages/agent-core-v2/src/human/llm/requester/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/requester.ts @@ -20,6 +20,8 @@ export interface ToolCallIdPolicy { maxLength?: number; } +export type LlmErrorClassifier = (error: unknown) => LlmRemoteErrorMessage | undefined; + export type LlmRequestEvent = | { type: 'llm.sent' } | { type: 'llm.streaming.headers'; headers: Record } @@ -28,7 +30,7 @@ export type LlmRequestEvent = | { type: 'llm.streaming.finish'; finish: FinishInfo } | { type: 'llm.streaming.message_id'; messageId: string } | { type: 'llm.failed.syntax'; error: LlmErrorMessage<'syntax'> } - | { type: 'llm.failed.remote'; error: LlmRemoteErrorMessage } + | { type: 'llm.failed.remote'; error: LlmRemoteErrorMessage; rawError?: unknown } | { type: 'llm.done' }; export interface ExtraParams { @@ -40,8 +42,20 @@ export interface ExtraParams { export type ToolMessageConversion = 'extract_text' | 'keep_parts'; +export interface LlmCredential { + readonly apiKey?: string; + readonly headers?: Record; +} + +export interface LlmCredentialProvider { + resolve(): Promise | LlmCredential | undefined; + canRecover?(error: unknown): boolean; + invalidate?(): void; +} + export interface LlmRequestConfig { readonly model: LlmModel; + readonly credentials?: LlmCredentialProvider; readonly systemPrompt?: string; readonly tools?: readonly ToolDescription[]; readonly cacheKey?: string; diff --git a/packages/agent-core-v2/src/human/persist/agent.ts b/packages/agent-core-v2/src/human/persist/agent.ts deleted file mode 100644 index fc72fa05482..00000000000 --- a/packages/agent-core-v2/src/human/persist/agent.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { ActorRefFrom } from 'xstate'; - -import type { HistoryMessage } from '#/agent/turn'; -import type { TurnEntryData, TurnOutcome } from '#/agent/replay'; -import type { Branch } from '#/store/branch'; -import type { AppendInput } from '#/store/types'; - -import type { createAgentMachine } from '#/agent/machine'; - -type AgentActor = ActorRefFrom>; -type AgentSnapshot = ReturnType; - -export interface PersistAgentOptions { - states?: Record unknown>; - persistedMessages?: number; - onError?: (error: unknown) => void; -} - -export interface AgentPersistence { - readonly persistedMessages: number; - flush(): Promise; - dispose(): void; -} - -interface BufferedTurnEvent { - kind: 'start' | 'end'; - turnId?: number; - outcome?: TurnOutcome; - messageCount?: number; -} - -function countPersistedMessages(branch: Branch): number { - let count = 0; - for (const entry of branch.walk()) { - if (entry.type === 'message') count += 1; - } - return count; -} - -export function persistAgent(actor: AgentActor, branch: Branch, opts?: PersistAgentOptions): AgentPersistence { - const report = opts?.onError ?? ((error: unknown) => console.error(error)); - const write = (input: AppendInput): void => { - void branch.append(input).catch(report); - }; - const writeMessage = (message: HistoryMessage): void => { - void branch.append({ type: 'message', kind: 'agent', data: message }).then( - () => { - completed += 1; - }, - report, - ); - }; - - let queued = opts?.persistedMessages ?? countPersistedMessages(branch); - let completed = queued; - let currentTurnId: number | undefined; - const buffered: BufferedTurnEvent[] = []; - const stateValues = new Map(); - for (const [name, get] of Object.entries(opts?.states ?? {})) { - stateValues.set(name, JSON.stringify(get())); - } - - const subscriptions = [ - actor.on('turn.started', (event) => { - if (event.type !== 'turn.started') return; - currentTurnId = event.turnId; - buffered.push({ kind: 'start', turnId: event.turnId }); - }), - actor.on('turn.done', (event) => { - if (event.type !== 'turn.done') return; - buffered.push({ kind: 'end', outcome: 'done', messageCount: event.messages.length }); - }), - actor.on('turn.failed', (event) => { - if (event.type !== 'turn.failed') return; - buffered.push({ kind: 'end', outcome: 'failed', messageCount: event.messages.length }); - }), - actor.on('turn.aborted', (event) => { - if (event.type !== 'turn.aborted') return; - buffered.push({ kind: 'end', outcome: 'aborted', messageCount: event.messages.length }); - }), - ]; - - const flushSnapshot = (snapshot: AgentSnapshot): void => { - const context = snapshot.context; - const writeUpTo = (count: number): void => { - while (queued < count) { - writeMessage(context.messages[queued] as HistoryMessage); - queued += 1; - } - }; - for (const event of buffered) { - if (event.kind === 'end') { - writeUpTo(event.messageCount ?? queued); - const data: TurnEntryData = { - phase: 'end', - turnId: currentTurnId, - outcome: event.outcome as TurnOutcome, - }; - write({ type: 'turn', kind: 'agent', data }); - } else { - writeUpTo(context.messages.length); - const data: TurnEntryData = { phase: 'start', turnId: event.turnId as number }; - write({ type: 'turn', kind: 'agent', data }); - } - } - buffered.length = 0; - writeUpTo(context.messages.length); - for (const [name, get] of Object.entries(opts?.states ?? {})) { - const value = get(); - const json = JSON.stringify(value); - if (json !== stateValues.get(name)) { - stateValues.set(name, json); - write({ type: 'state', kind: 'agent', data: { name, value } }); - } - } - }; - - const subscription = actor.subscribe((snapshot) => { - flushSnapshot(snapshot); - }); - - return { - get persistedMessages() { - return completed; - }, - flush: () => branch.settled(), - dispose: () => { - subscription.unsubscribe(); - for (const sub of subscriptions) { - sub.unsubscribe(); - } - }, - }; -} diff --git a/packages/agent-core-v2/src/human/persist/open.ts b/packages/agent-core-v2/src/human/persist/open.ts index 76d0ae91548..7954cfc96ad 100644 --- a/packages/agent-core-v2/src/human/persist/open.ts +++ b/packages/agent-core-v2/src/human/persist/open.ts @@ -2,6 +2,8 @@ import { NodeBackend } from '#/store/backend/node'; import { TreeStore } from '#/store/store'; import type { Tree } from '#/store/tree'; +import { SessionStores } from '#/session/stores'; + import { isV2SessionDir, migrateV2Session, V2_SESSION_TREE_NAME } from './v2/migrate'; export interface OpenSessionStoreOptions { @@ -12,6 +14,7 @@ export interface OpenSessionStoreOptions { export interface OpenedSessionStore { store: TreeStore; tree: Tree; + stores: SessionStores; migrated: boolean; } @@ -24,7 +27,8 @@ export async function openSessionStore( await migrateV2Session(dir); migrated = true; } - const store = await TreeStore.open(new NodeBackend(dir), { fsync: opts?.fsync ?? false }); + const backend = new NodeBackend(dir); + const store = await TreeStore.open(backend, { fsync: opts?.fsync ?? false }); const tree = await store.tree(opts?.treeName ?? V2_SESSION_TREE_NAME); - return { store, tree, migrated }; + return { store, tree, stores: new SessionStores(tree, backend), migrated }; } diff --git a/packages/agent-core-v2/src/human/persist/session.ts b/packages/agent-core-v2/src/human/persist/session.ts deleted file mode 100644 index 9074d99ad70..00000000000 --- a/packages/agent-core-v2/src/human/persist/session.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { ActorRefFrom } from '#/xstate2'; - -import { loadAgentState, type LoadedAgentState } from '#/agent/replay'; -import { persistAgent, type AgentPersistence } from '#/persist/agent'; -import type { Branch } from '#/store/branch'; -import type { Tree } from '#/store/tree'; - -import type { AgentActorRef, createSessionMachine } from '#/session/machine'; - -type SessionActor = ActorRefFrom>; - -export interface PersistSessionOptions { - states?: (agentId: string) => Record unknown>; - branches?: ReadonlyMap; - onError?: (error: unknown) => void; -} - -export interface SessionPersistence { - flush(): Promise; - dispose(): void; -} - -export interface LoadedSessionAgent extends LoadedAgentState { - agentId: string; - branch: string; -} - -export interface LoadedSessionState { - agents: LoadedSessionAgent[]; - meta?: unknown; -} - -interface AgentHandle { - persistence: AgentPersistence; - branch: Branch; -} - -export const SESSION_LOG_BRANCH = '_session'; -export const SESSION_AGENT_OPEN_ENTRY_TYPE = 'agent.open'; -export const SESSION_META_ENTRY_TYPE = 'session.meta'; -const CLOSE_ENTRY_TYPE = 'agent.close'; -const SWITCH_ENTRY_TYPE = 'agent.switch'; - -export function persistSession( - session: SessionActor, - tree: Tree, - opts?: PersistSessionOptions, -): SessionPersistence { - const report = opts?.onError ?? ((error: unknown) => console.error(error)); - const handles = new Map(); - let logBranch: Branch | undefined; - - const log = (): Branch => { - logBranch ??= tree.has(SESSION_LOG_BRANCH) - ? tree.openBranch(SESSION_LOG_BRANCH) - : tree.createBranch(SESSION_LOG_BRANCH); - return logBranch; - }; - - const attach = ( - agentId: string, - ref: AgentActorRef, - branch: Branch, - persistedMessages?: number, - ): void => { - const persistence = persistAgent(ref, branch, { - states: opts?.states?.(agentId), - onError: report, - persistedMessages, - }); - handles.set(agentId, { persistence, branch }); - }; - - const attachCreated = (agentId: string, branchId: string, ref: AgentActorRef): void => { - const mapped = opts?.branches?.get(agentId); - if (mapped !== undefined) { - attach(agentId, ref, tree.openBranch(mapped)); - return; - } - if (tree.has(branchId)) { - report(new Error(`branch '${branchId}' already exists for agent '${agentId}'`)); - return; - } - const branch = tree.createBranch(branchId); - void log() - .append({ type: SESSION_AGENT_OPEN_ENTRY_TYPE, kind: 'session', data: { agentId, branch: branch.name } }) - .catch(report); - attach(agentId, ref, branch); - }; - - const attachForked = ( - sourceId: string, - agentId: string, - branchId: string, - ref: AgentActorRef, - ): void => { - const source = handles.get(sourceId); - if (source === undefined) { - report(new Error(`cannot persist fork of unknown agent '${sourceId}'`)); - return; - } - if (tree.has(branchId)) { - report(new Error(`branch '${branchId}' already exists for agent '${agentId}'`)); - return; - } - const head = source.branch.head; - const branch = - head === null - ? tree.createBranch(branchId) - : tree.createBranch(branchId, { from: { branch: source.branch.name, seq: head } }); - void log() - .append({ type: SESSION_AGENT_OPEN_ENTRY_TYPE, kind: 'session', data: { agentId, branch: branch.name } }) - .catch(report); - attach(agentId, ref, branch, source.persistence.persistedMessages); - }; - - const attachSwitched = (agentId: string, branchId: string, reason?: string): void => { - const entry = session.getSnapshot().context.agents[agentId]; - if (entry === undefined) return; - const previous = handles.get(agentId); - const from = - previous === undefined - ? null - : { branch: previous.branch.name, seq: previous.branch.head }; - previous?.persistence.dispose(); - attach(agentId, entry.ref, tree.openBranch(branchId)); - void log() - .append({ - type: SWITCH_ENTRY_TYPE, - kind: 'session', - data: { agentId, branch: branchId, reason, from }, - }) - .catch(report); - }; - - const detach = (agentId: string): void => { - const handle = handles.get(agentId); - if (handle === undefined) return; - handles.delete(agentId); - handle.persistence.dispose(); - void log().append({ type: CLOSE_ENTRY_TYPE, kind: 'session', data: { agentId } }).catch(report); - }; - - for (const [agentId, entry] of Object.entries(session.getSnapshot().context.agents)) { - attachCreated(agentId, entry.ref.getSnapshot().context.branchId, entry.ref); - } - - const subscriptions = [ - session.on('agent.created', (event) => { - if (event.type !== 'agent.created') return; - attachCreated(event.agentId, event.branchId, event.ref); - }), - session.on('agent.forked', (event) => { - if (event.type !== 'agent.forked') return; - attachForked(event.sourceId, event.agentId, event.branchId, event.ref); - }), - session.on('agent.switched', (event) => { - if (event.type !== 'agent.switched') return; - attachSwitched(event.agentId, event.branchId, event.reason); - }), - session.on('agent.stopped', (event) => { - if (event.type !== 'agent.stopped') return; - detach(event.agentId); - }), - ]; - - return { - flush: async () => { - await Promise.all([...handles.values()].map((handle) => handle.persistence.flush())); - await logBranch?.settled(); - }, - dispose: () => { - for (const subscription of subscriptions) { - subscription.unsubscribe(); - } - for (const handle of handles.values()) { - handle.persistence.dispose(); - } - handles.clear(); - }, - }; -} - -export async function loadSessionState(tree: Tree): Promise { - const agents: LoadedSessionAgent[] = []; - if (!tree.has(SESSION_LOG_BRANCH)) return { agents }; - const open = new Map(); - let meta: unknown; - const entries = [...tree.openBranch(SESSION_LOG_BRANCH).walk()].toReversed(); - for (const entry of entries) { - const data = await tree.resolve(entry); - if (entry.type === SESSION_AGENT_OPEN_ENTRY_TYPE || entry.type === SWITCH_ENTRY_TYPE) { - const opened = data as { agentId: string; branch: string }; - open.set(opened.agentId, opened.branch); - } else if (entry.type === CLOSE_ENTRY_TYPE) { - const closed = data as { agentId: string }; - open.delete(closed.agentId); - } else if (entry.type === SESSION_META_ENTRY_TYPE) { - meta = data; - } - } - for (const [agentId, branch] of [...open.entries()].sort(([a], [b]) => a.localeCompare(b))) { - const loaded = await loadAgentState(tree, branch); - agents.push({ agentId, branch, ...loaded }); - } - return meta === undefined ? { agents } : { agents, meta }; -} diff --git a/packages/agent-core-v2/src/human/persist/v2/migrate.ts b/packages/agent-core-v2/src/human/persist/v2/migrate.ts index 8b71c6e1bed..71ea0b4ce1e 100644 --- a/packages/agent-core-v2/src/human/persist/v2/migrate.ts +++ b/packages/agent-core-v2/src/human/persist/v2/migrate.ts @@ -1,7 +1,8 @@ import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'; import { basename, join } from 'node:path'; -import { SESSION_AGENT_OPEN_ENTRY_TYPE, SESSION_LOG_BRANCH, SESSION_META_ENTRY_TYPE } from '#/persist/session'; +import { messageAppended, stateUpdated, turnEnded } from '#/agent/events'; +import { agentOpened, SESSION_LOG_BRANCH, sessionMetaUpdated } from '#/session/events'; import { NodeBackend } from '#/store/backend/node'; import { TreeStore } from '#/store/store'; import type { Branch } from '#/store/branch'; @@ -174,33 +175,37 @@ export async function migrateV2Session(dir: string): Promise resolveBlob, ); if (converted === null) continue; - await branch.append({ type: 'message', kind: 'agent', data: converted }); + await branch.append({ + type: messageAppended.type, + kind: 'event', + data: messageAppended({ message: converted }), + }); } const lastTurnId = folded.nextTurnId - 1; if (folded.todos.length > 0) { await branch.append({ - type: 'state', - kind: 'agent', - data: { + type: stateUpdated.type, + kind: 'event', + data: stateUpdated({ name: 'todo', value: { todos: folded.todos, lastWriteTurn: Math.max(0, lastTurnId) }, - }, + }), }); } if (folded.nextTurnId > 0) { await branch.append({ - type: 'turn', - kind: 'agent', - data: { phase: 'start', turnId: lastTurnId }, + type: turnEnded.type, + kind: 'event', + data: turnEnded({ turnId: lastTurnId, outcome: 'done' }), }); } await log.append({ - type: SESSION_AGENT_OPEN_ENTRY_TYPE, - kind: 'session', - data: { agentId, branch: agentId }, + type: agentOpened.type, + kind: 'event', + data: agentOpened({ agentId, branch: agentId }), }); } - await log.append({ type: SESSION_META_ENTRY_TYPE, kind: 'session', data: meta }); + await log.append({ type: sessionMetaUpdated.type, kind: 'event', data: sessionMetaUpdated({ meta }) }); for (const branchName of tree.branches()) { await tree.openBranch(branchName).settled(); } diff --git a/packages/agent-core-v2/src/human/session/events.ts b/packages/agent-core-v2/src/human/session/events.ts new file mode 100644 index 00000000000..a40131d2057 --- /dev/null +++ b/packages/agent-core-v2/src/human/session/events.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { defineEvent } from '#/eventStore/events'; + +export const SESSION_LOG_BRANCH = '_session'; + +export const agentOpened = defineEvent({ + type: 'agent.opened', + schema: z.object({ agentId: z.string(), branch: z.string() }), +}); +export type AgentOpened = ReturnType; + +export const agentClosed = defineEvent({ + type: 'agent.closed', + schema: z.object({ agentId: z.string() }), +}); +export type AgentClosed = ReturnType; + +export const agentSwitched = defineEvent({ + type: 'agent.switched', + schema: z.object({ agentId: z.string(), branch: z.string(), reason: z.string().optional() }), +}); +export type AgentSwitched = ReturnType; + +export const sessionMetaUpdated = defineEvent({ + type: 'session.meta_updated', + schema: z.object({ meta: z.unknown() }), +}); +export type SessionMetaUpdated = ReturnType; diff --git a/packages/agent-core-v2/src/human/session/machine.ts b/packages/agent-core-v2/src/human/session/machine.ts index e9582726173..8106f5db54f 100644 --- a/packages/agent-core-v2/src/human/session/machine.ts +++ b/packages/agent-core-v2/src/human/session/machine.ts @@ -1,8 +1,9 @@ import { assign, emit, sendTo, setup, stopChild, type ActorRefFrom } from '#/xstate2'; +import type { createAgentMachine, AgentEvent } from '#/agent/machine'; +import type { AgentEventStore } from '#/agent/slices'; import type { LlmRequestConfig } from '#/llm/requester/requester'; -import type { createAgentMachine, AgentEvent, AgentInput } from '#/agent/machine'; -import type { HistoryMessage, TurnLlmEvent, TurnToolEvent } from '#/agent/turn'; +import type { TurnLlmEvent, TurnToolEvent } from '#/agent/turn'; import type { ToolUpdate } from '#/tool/executor'; export interface SessionInput { @@ -19,20 +20,18 @@ export type SessionEvent = | TurnLlmEvent | TurnToolEvent | { type: 'tool.update'; toolCallId: string; update: ToolUpdate } - | { type: 'agent.create'; agentId?: string; input?: Pick } - | { type: 'agent.fork'; sourceId: string; agentId?: string } | { - type: 'agent.switch'; - agentId: string; - input: { branchId: string; history: readonly HistoryMessage[]; turnId: number; reason?: string }; + type: 'agent.create'; + agentId?: string; + input: { request?: LlmRequestConfig; store: AgentEventStore }; } + | { type: 'agent.fork'; sourceId: string; agentId?: string; store: AgentEventStore } | { type: 'agent.send'; agentId: string; event: AgentEvent } | { type: 'agent.stop'; agentId: string }; export type SessionEmitted = | { type: 'agent.created'; agentId: string; branchId: string; ref: AgentActorRef } | { type: 'agent.forked'; sourceId: string; agentId: string; branchId: string; ref: AgentActorRef } - | { type: 'agent.switched'; agentId: string; branchId: string; reason?: string } | { type: 'agent.stopped'; agentId: string } | { type: 'agent.failed'; agentId: string; error: string }; @@ -86,6 +85,8 @@ export function createSessionMachine({ agent }: CreateSessionMachineOptions) { 'tool.failed': {}, 'tool.aborted': {}, 'context.reset': {}, + 'store.reset': {}, + 'store.error': {}, }, states: { active: { @@ -111,10 +112,8 @@ export function createSessionMachine({ agent }: CreateSessionMachineOptions) { const ref = spawn('agentActor', { id: agentId, input: { - request: event.input?.request ?? context.input.request, - history: event.input?.history, - turnId: event.input?.turnId, - branchId: event.input?.branchId ?? agentId, + request: event.input.request ?? context.input.request, + store: event.input.store, }, }); return { @@ -128,7 +127,7 @@ export function createSessionMachine({ agent }: CreateSessionMachineOptions) { return { type: 'agent.created' as const, agentId, - branchId: event.input?.branchId ?? agentId, + branchId: event.input.store.ref.branch, ref: entry.ref, }; }), @@ -162,9 +161,7 @@ export function createSessionMachine({ agent }: CreateSessionMachineOptions) { id: agentId, input: { request: source.context.input.request, - history: [...source.context.messages], - turnId: source.context.turnId, - branchId: agentId, + store: event.store, }, }); return { @@ -179,51 +176,13 @@ export function createSessionMachine({ agent }: CreateSessionMachineOptions) { type: 'agent.forked' as const, sourceId: event.sourceId, agentId, - branchId: agentId, + branchId: event.store.ref.branch, ref: entry.ref, }; }), ], }, ], - 'agent.switch': [ - { - guard: ({ context, event }) => context.agents[event.agentId] === undefined, - actions: emit(({ event }) => ({ - type: 'agent.failed' as const, - agentId: event.agentId, - error: `unknown agent: '${event.agentId}'`, - })), - }, - { - guard: ({ context, event }) => - !(context.agents[event.agentId] as AgentEntry).ref.getSnapshot().matches('idle'), - actions: emit(({ event }) => ({ - type: 'agent.failed' as const, - agentId: event.agentId, - error: `agent is busy: '${event.agentId}'`, - })), - }, - { - actions: [ - sendTo( - ({ context, event }) => (context.agents[event.agentId] as AgentEntry).ref, - ({ event }) => ({ - type: 'context.reset' as const, - history: event.input.history, - turnId: event.input.turnId, - branchId: event.input.branchId, - }), - ), - emit(({ event }) => ({ - type: 'agent.switched' as const, - agentId: event.agentId, - branchId: event.input.branchId, - reason: event.input.reason, - })), - ], - }, - ], 'agent.send': [ { guard: ({ context, event }) => context.agents[event.agentId] === undefined, diff --git a/packages/agent-core-v2/src/human/session/slices.ts b/packages/agent-core-v2/src/human/session/slices.ts new file mode 100644 index 00000000000..665bd1b2016 --- /dev/null +++ b/packages/agent-core-v2/src/human/session/slices.ts @@ -0,0 +1,39 @@ +import { createSlice } from '#/eventStore/slice'; + +import type { AgentClosed, AgentOpened, AgentSwitched, SessionMetaUpdated } from './events'; + +export interface RosterState { + agents: Record; +} + +export const rosterSlice = createSlice({ + name: 'roster', + initialState: (): RosterState => ({ agents: {} }), + reducers: { + 'agent.opened': (draft, event: AgentOpened) => { + draft.agents[event.agentId] = event.branch; + }, + 'agent.closed': (draft, event: AgentClosed) => { + delete draft.agents[event.agentId]; + }, + 'agent.switched': (draft, event: AgentSwitched) => { + draft.agents[event.agentId] = event.branch; + }, + }, +}); + +export interface SessionMetaState { + value: unknown; +} + +export const sessionMetaSlice = createSlice({ + name: 'sessionMeta', + initialState: (): SessionMetaState => ({ value: undefined }), + reducers: { + 'session.meta_updated': (draft, event: SessionMetaUpdated) => { + draft.value = event.meta; + }, + }, +}); + +export const sessionSlices = { roster: rosterSlice, sessionMeta: sessionMetaSlice }; diff --git a/packages/agent-core-v2/src/human/session/stores.ts b/packages/agent-core-v2/src/human/session/stores.ts new file mode 100644 index 00000000000..aa3e7da82ac --- /dev/null +++ b/packages/agent-core-v2/src/human/session/stores.ts @@ -0,0 +1,149 @@ +import { createEventStore, type EventStore } from '#/eventStore/eventStore'; +import { journalFromBranch } from '#/eventStore/journal'; +import { agentSlices, type AgentEventStore } from '#/agent/slices'; +import type { StoreBackend } from '#/store/backend/backend'; +import { StoreError, type BranchRef } from '#/store/types'; +import type { Tree } from '#/store/tree'; + +import { agentClosed, agentOpened, agentSwitched, SESSION_LOG_BRANCH } from './events'; +import { sessionSlices } from './slices'; + +export type SessionStore = EventStore; + +export type UndoErrorReason = 'unknown-agent' | 'invalid-count' | 'insufficient'; + +export class UndoError extends Error { + readonly reason: UndoErrorReason; + + constructor(reason: UndoErrorReason, message: string) { + super(message); + this.name = 'UndoError'; + this.reason = reason; + } +} + +export function isValidUndoCount(count: number): boolean { + return Number.isSafeInteger(count) && count > 0; +} + +export function freshBranchName(tree: Tree, agentId: string): string { + if (!tree.has(agentId)) return agentId; + let n = 2; + while (tree.has(`${agentId}~${n}`)) n += 1; + return `${agentId}~${n}`; +} + +function undoForkRef(tree: Tree, start: BranchRef): BranchRef | undefined { + if (start.seq > 0) return { branch: start.branch, seq: start.seq - 1 }; + const header = tree.openBranch(start.branch).header; + if (header.parentBranch !== undefined && header.parentSeq !== undefined) { + return { branch: header.parentBranch, seq: header.parentSeq }; + } + return undefined; +} + +export class SessionStores { + private readonly agents = new Map(); + private sessionStore: SessionStore | undefined; + + constructor( + readonly tree: Tree, + readonly backend: StoreBackend, + ) {} + + get(agentId: string): AgentEventStore | undefined { + return this.agents.get(agentId); + } + + async session(): Promise { + if (this.sessionStore === undefined) { + const branch = this.tree.has(SESSION_LOG_BRANCH) + ? this.tree.openBranch(SESSION_LOG_BRANCH) + : this.tree.createBranch(SESSION_LOG_BRANCH); + this.sessionStore = await createEventStore({ + journal: journalFromBranch(branch, this.tree), + slices: sessionSlices, + }); + } + return this.sessionStore; + } + + async open(agentId: string, opts?: { from?: BranchRef }): Promise { + const existing = this.agents.get(agentId); + if (existing !== undefined) { + return existing; + } + const existed = this.tree.has(agentId); + const branch = existed + ? this.tree.openBranch(agentId) + : this.tree.createBranch(agentId, opts?.from !== undefined ? { from: opts.from } : undefined); + const engine = await createEventStore({ + journal: journalFromBranch(branch, this.tree), + slices: agentSlices, + }); + this.agents.set(agentId, engine); + if (!existed) { + await (await this.session()).dispatch(agentOpened({ agentId, branch: branch.name })); + } + return engine; + } + + async fork(sourceId: string, agentId: string): Promise { + const source = this.agents.get(sourceId); + if (source === undefined) { + throw new StoreError('unknown-agent', `unknown agent '${sourceId}'`); + } + const sourceBranch = this.tree.openBranch(source.ref.branch); + const head = sourceBranch.head; + return this.open( + agentId, + head === null ? undefined : { from: { branch: sourceBranch.name, seq: head } }, + ); + } + + async close(agentId: string): Promise { + const store = this.agents.get(agentId); + if (store === undefined) return; + this.agents.delete(agentId); + await store.close(); + await (await this.session()).dispatch(agentClosed({ agentId })); + } + + async undo(agentId: string, turns: number): Promise<{ branchId: string }> { + const store = this.agents.get(agentId); + if (store === undefined) { + throw new UndoError('unknown-agent', `unknown agent: '${agentId}'`); + } + if (!isValidUndoCount(turns)) { + throw new UndoError('invalid-count', `invalid undo count: ${turns}`); + } + const index = store.slice('turnIndex').turns; + const cut = index.at(-turns); + if (cut === undefined) { + throw new UndoError('insufficient', `cannot undo ${turns} turn(s): not enough turns`); + } + const from = undoForkRef(this.tree, cut.start); + const branchId = freshBranchName(this.tree, agentId); + const branch = + from === undefined + ? this.tree.createBranch(branchId) + : this.tree.createBranch(branchId, { from }); + await store.reset(journalFromBranch(branch, this.tree)); + await ( + await this.session() + ).dispatch(agentSwitched({ agentId, branch: branchId, reason: 'undo' })); + return { branchId }; + } + + async flush(): Promise { + await Promise.all([...this.agents.values()].map((store) => store.flush())); + await this.sessionStore?.flush(); + } + + async dispose(): Promise { + await Promise.all([...this.agents.values()].map((store) => store.close())); + this.agents.clear(); + await this.sessionStore?.close(); + this.sessionStore = undefined; + } +} diff --git a/packages/agent-core-v2/src/human/session/undo.ts b/packages/agent-core-v2/src/human/session/undo.ts deleted file mode 100644 index 470e95f83de..00000000000 --- a/packages/agent-core-v2/src/human/session/undo.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { ActorRefFrom } from '#/xstate2'; - -import { loadAgentState, type LoadedAgentState } from '#/agent/replay'; -import type { HistoryMessage, UserMeta } from '#/agent/turn'; -import type { Branch } from '#/store/branch'; -import type { Tree } from '#/store/tree'; -import type { BranchRef, BranchHeader } from '#/store/types'; - -import type { createSessionMachine } from '#/session/machine'; - -type SessionActor = ActorRefFrom>; - -export type UndoErrorReason = 'unknown-agent' | 'invalid-count' | 'busy' | 'insufficient'; - -export class UndoError extends Error { - readonly reason: UndoErrorReason; - - constructor(reason: UndoErrorReason, message: string) { - super(message); - this.name = 'UndoError'; - this.reason = reason; - } -} - -export interface UndoResult extends LoadedAgentState { - agentId: string; - branchId: string; -} - -export function isUndoAnchor(entry: HistoryMessage): boolean { - if (entry.message.role !== 'user') return false; - const source = (entry.meta as UserMeta).source; - return source === undefined || source === 'input'; -} - -export function isValidUndoCount(count: number): boolean { - return Number.isSafeInteger(count) && count > 0; -} - -export function freshBranchName(tree: Tree, agentId: string): string { - if (!tree.has(agentId)) return agentId; - let n = 2; - while (tree.has(`${agentId}~${n}`)) n += 1; - return `${agentId}~${n}`; -} - -export async function findUndoCut( - tree: Tree, - start: Branch, - turns: number, -): Promise { - let remaining = turns; - let branch: Branch | undefined = start; - let from: number | null = start.head; - while (branch !== undefined) { - for (let seq = from ?? -1; seq >= 0; seq--) { - const entry = branch.entryAt(seq); - if (entry === null || entry.type !== 'message') continue; - const data = await tree.resolve(entry); - if (!isUndoAnchor(data as HistoryMessage)) continue; - remaining -= 1; - if (remaining > 0) continue; - if (seq > 0) return { branch: branch.name, seq: seq - 1 }; - const { parentBranch, parentSeq } = branch.header; - if (parentBranch !== undefined && parentSeq !== undefined) { - return { branch: parentBranch, seq: parentSeq }; - } - return null; - } - const header: BranchHeader = branch.header; - const next: Branch | undefined = - header.parentBranch !== undefined && - header.parentSeq !== undefined && - tree.has(header.parentBranch) - ? tree.openBranch(header.parentBranch) - : undefined; - from = header.parentSeq ?? null; - branch = next; - } - throw new UndoError('insufficient', `cannot undo ${turns} turn(s): not enough anchors`); -} - -export async function undoAgentTurns( - session: SessionActor, - tree: Tree, - agentId: string, - turns: number, -): Promise { - const entry = session.getSnapshot().context.agents[agentId]; - if (entry === undefined) { - throw new UndoError('unknown-agent', `unknown agent: '${agentId}'`); - } - if (!isValidUndoCount(turns)) { - throw new UndoError('invalid-count', `invalid undo count: ${turns}`); - } - const snapshot = entry.ref.getSnapshot(); - if (!snapshot.matches('idle')) { - throw new UndoError('busy', `agent is busy: '${agentId}'`); - } - if (!tree.has(snapshot.context.branchId)) { - throw new UndoError('insufficient', `cannot undo ${turns} turn(s): not enough anchors`); - } - const branch = tree.openBranch(snapshot.context.branchId); - await branch.settled(); - const cut = await findUndoCut(tree, branch, turns); - const branchId = freshBranchName(tree, agentId); - tree.createBranch(branchId, cut === null ? undefined : { from: cut }); - const loaded = await loadAgentState(tree, branchId); - session.send({ - type: 'agent.switch', - agentId, - input: { branchId, history: loaded.messages, turnId: loaded.turnId, reason: 'undo' }, - }); - if (entry.ref.getSnapshot().context.branchId !== branchId) { - throw new UndoError('busy', `agent is busy: '${agentId}'`); - } - return { agentId, branchId, ...loaded }; -} diff --git a/packages/agent-core-v2/src/human/test/agent/machine.test.ts b/packages/agent-core-v2/src/human/test/agent/machine.test.ts index c45a93e9218..6916fe02d6c 100644 --- a/packages/agent-core-v2/src/human/test/agent/machine.test.ts +++ b/packages/agent-core-v2/src/human/test/agent/machine.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createActor, waitFor } from '#/xstate2'; -const originalWarn = console.warn; -console.warn = (...args: unknown[]) => { - originalWarn(...args); - originalWarn(new Error('warn-trace').stack?.split('\n').slice(1, 16).join('\n')); -}; - import { UNKNOWN_CAPABILITY } from '#/llm/capability'; import { createAssistantMessage, @@ -17,7 +11,7 @@ import { type ToolCall, } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; -import { createLlmMachine, type LlmEvent } from '#/llm/requester/machine'; +import type { LlmEvent } from '#/llm/requester/actor'; import type { LlmRequester, LlmRequestEvent } from '#/llm/requester/requester'; import type { LlmRetryOptions } from '#/llm/requester/retry'; import { emptyUsage, type TokenUsage } from '#/llm/usage'; @@ -25,6 +19,8 @@ import { connectPlugins } from '#/plugin'; import { createTimingPlugin } from '#/timing/plugin'; import { createAgentMachine, type AgentEmitted } from '#/agent/machine'; import { estimateMessageTokens, estimateTextTokens } from '#/agent/context-usage'; +import { messageAppended, turnEnded } from '#/agent/events'; +import { agentSlices, type AgentEventStore } from '#/agent/slices'; import { createTurnMachine, createUserEntry, @@ -33,6 +29,11 @@ import { type HistoryMessage, } from '#/agent/turn'; import { MaxStepsExceededError } from '#/agent/errors'; +import { createEventStore } from '#/eventStore/eventStore'; +import { journalFromBranch } from '#/eventStore/journal'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; +import type { Tree } from '#/store/tree'; import { waitForTool } from '#/tool/wait-for'; import { defineTool, type ToolDefinition } from '#/tool/tool'; import type { ToolResult } from '#/tool/executor'; @@ -78,7 +79,7 @@ function createTestAgentMachine( ) { return createAgentMachine({ tools, - turnActor: createTurnMachine(createLlmMachine({ requester }), { retry }), + turnActor: createTurnMachine(requester, { retry }), abortTimeoutMs, }); } @@ -97,22 +98,44 @@ function stubTools( ); } +async function testStore(): Promise { + const backend = new MemoryBackend(); + const store = await TreeStore.open(backend, {}); + const tree = await store.tree('test'); + tree.createBranch('main'); + return createEventStore({ journal: journalFromBranch(tree.openBranch('main'), tree), slices: agentSlices }); +} + +async function seedBranch(tree: Tree, branch: string, texts: readonly string[]): Promise { + tree.createBranch(branch); + const seed = await createEventStore({ + journal: journalFromBranch(tree.openBranch(branch), tree), + slices: agentSlices, + }); + for (const text of texts) { + await seed.dispatch( + messageAppended({ message: createUserEntry(createUserMessage(text), { source: 'input' }) }), + ); + } + await seed.dispatch(turnEnded({ turnId: 0, outcome: 'done' })); + await seed.close(); +} + async function runAgent( requester: LlmRequester, tools: readonly ToolDefinition[], retry?: LlmRetryOptions, ): Promise { - const actor = createActor(createTestAgentMachine(tools, requester, retry), { - input: { request: { model } }, + const store = await testStore(); + const actor = createActor(createTestAgentMachine(tools, requester, retry), { + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - const snapshot = await waitFor( - actor, - (s) => s.matches('idle') && s.context.messages.length > 1, - { timeout: 5000 }, - ); - return snapshot.context.messages; + await waitFor(actor, (s) => s.matches('idle') && store.getState().history.length > 1, { + timeout: 5000, + }); + return store.getState().history; } function rolesAndTexts(messages: readonly HistoryMessage[]): string[] { @@ -358,8 +381,9 @@ describe('agent machine async tools', () => { resolveBg = resolve; }); }, 'bg_tool'); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -373,13 +397,13 @@ describe('agent machine async tools', () => { ).not.toBe('done'); resolveBg?.({ content: [{ type: 'text', text: 'bg-result' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 6, + (s) => s.matches('idle') && store.getState().history.length === 6, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:async running: bg_tool', @@ -405,8 +429,9 @@ describe('agent machine async tools', () => { resolvers.set(call.name, resolve); }); }, 'bg_tool', 'sync_tool'); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -421,13 +446,13 @@ describe('agent machine async tools', () => { }); resolvers.get('bg_tool')?.({ content: [{ type: 'text', text: 'bg-result' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 7, + (s) => s.matches('idle') && store.getState().history.length === 7, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:async running: bg_tool', @@ -457,8 +482,9 @@ describe('agent machine async tools', () => { }, }); const tools = [bgTool, waitForTool]; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -467,13 +493,13 @@ describe('agent machine async tools', () => { expect(actor.getSnapshot().context.turnTools['call-2']).toBeDefined(); }); resolveBg?.({ content: [{ type: 'text', text: 'bg-result' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 7, + (s) => s.matches('idle') && store.getState().history.length === 7, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:async running: bg_tool', @@ -506,8 +532,9 @@ describe('agent machine async tools', () => { }, }); const tools = [bgTool, waitForTool]; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -519,13 +546,13 @@ describe('agent machine async tools', () => { { timeout: 4000 }, ); resolveBg?.({ content: [{ type: 'text', text: 'bg-result' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 8, + (s) => s.matches('idle') && store.getState().history.length === 8, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:async running: bg_tool', @@ -563,26 +590,27 @@ describe('agent machine lifecycle', () => { createAssistantMessage([{ type: 'text', text: 'second' }]), ]); const tools: ToolDefinition[] = []; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); const completedTurns: number[] = []; actor.on('turn.done', (event) => completedTurns.push(event.messages.length)); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 2, { + await waitFor(actor, (s) => s.matches('idle') && store.getState().history.length === 2, { timeout: 5000, }); actor.send({ type: 'input.submit', message: createUserMessage('again') }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 4, + (s) => s.matches('idle') && store.getState().history.length === 4, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:first', 'user:again', @@ -606,8 +634,9 @@ describe('agent machine lifecycle', () => { }), 'slow_tool', ); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -616,17 +645,19 @@ describe('agent machine lifecycle', () => { expect(resolveTool).toBeDefined(); }); actor.send({ type: 'input.submit', message: createUserMessage('mid-turn') }); - expect(actor.getSnapshot().context.queue).toHaveLength(1); - expect(actor.getSnapshot().context.notifications).toHaveLength(0); + await vi.waitFor(() => { + expect(store.getState().queue).toHaveLength(1); + expect(store.getState().notifications).toHaveLength(0); + }); resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 6, + (s) => s.matches('idle') && store.getState().history.length === 6, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:slow', @@ -650,35 +681,63 @@ describe('agent machine lifecycle', () => { }, }; const tools: ToolDefinition[] = []; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); const failures: unknown[] = []; actor.on('turn.failed', (event) => failures.push(event.error)); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - const failedSnapshot = await waitFor( + await waitFor( actor, (s) => s.matches('idle') && failures.length === 1, { timeout: 5000 }, ); - expect(rolesAndTexts(failedSnapshot.context.messages)).toEqual(['user:hi']); + await vi.waitFor(() => { + expect(rolesAndTexts(store.getState().history)).toEqual(['user:hi']); + }); expect(failures[0]).toMatchObject({ kind: 'unknown', message: 'llm down' }); expect(actor.getSnapshot().status).toBe('active'); actor.send({ type: 'input.submit', message: createUserMessage('retry') }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 3, + (s) => s.matches('idle') && store.getState().history.length === 3, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'user:retry', 'assistant:recovered', ]); }); + + it('persists turn events without reporting unhandled store.changed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const requester = createStubRequester([ + createAssistantMessage([{ type: 'text', text: 'hi' }]), + ]); + const store = await testStore(); + const actor = createActor(createTestAgentMachine([], requester), { + input: { request: { model }, store }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hello') }); + await waitFor(actor, (s) => s.matches('idle') && store.getState().history.length === 2, { + timeout: 5000, + }); + await store.flush(); + const unhandled = warn.mock.calls.filter(([message]) => + String(message).includes('unhandled event "store.changed"'), + ); + expect(unhandled).toEqual([]); + } finally { + warn.mockRestore(); + } + }); }); describe('agent machine input.notify', () => { @@ -695,8 +754,9 @@ describe('agent machine input.notify', () => { }), 'slow_tool', ); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -708,16 +768,18 @@ describe('agent machine input.notify', () => { type: 'input.notify', message: createUserMessage('stale'), }); - expect(actor.getSnapshot().context.notifications).toHaveLength(1); + await vi.waitFor(() => { + expect(store.getState().notifications).toHaveLength(1); + }); resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 5, + (s) => s.matches('idle') && store.getState().history.length === 5, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:slow', @@ -744,8 +806,9 @@ describe('agent machine input.notify', () => { }, }; const tools: ToolDefinition[] = []; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); const completedTurns: number[] = []; actor.on('turn.done', (event) => completedTurns.push(event.messages.length)); @@ -759,17 +822,19 @@ describe('agent machine input.notify', () => { type: 'input.notify', message: createUserMessage('stale'), }); - expect(actor.getSnapshot().context.notifications).toHaveLength(1); + await vi.waitFor(() => { + expect(store.getState().notifications).toHaveLength(1); + }); streamMessage(createAssistantMessage([{ type: 'text', text: 'first' }]), firstOnEvent); resolveFirst?.(); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 4, + (s) => s.matches('idle') && store.getState().history.length === 4, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:first', 'user:stale', @@ -783,8 +848,9 @@ describe('agent machine input.notify', () => { createAssistantMessage([{ type: 'text', text: 'done' }]), ]); const tools: ToolDefinition[] = []; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); const llmDone: AssistantEntry[] = []; actor.on('llm.done', (event) => { @@ -793,13 +859,13 @@ describe('agent machine input.notify', () => { actor.start(); actor.send({ type: 'input.notify', message: createUserMessage('queued') }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 2, + (s) => s.matches('idle') && store.getState().history.length === 2, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual(['user:queued', 'assistant:done']); + expect(rolesAndTexts(store.getState().history)).toEqual(['user:queued', 'assistant:done']); expect(llmDone.map((entry) => extractText(entry.message))).toEqual(['done']); expect(llmDone[0]?.meta).toEqual({ model: { provider: 'test', model: 'test-model' }, @@ -826,8 +892,9 @@ describe('agent machine input.remind', () => { }), 'slow_tool', ); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); const consumedKeys: (string | undefined)[][] = []; actor.on('turn.reminders_consumed', (event) => { @@ -847,36 +914,37 @@ describe('agent machine input.remind', () => { key: 'todo', message: createUserMessage('\nstale\n'), }); - await new Promise((resolve) => setTimeout(resolve, 50)); - const idleSnapshot = actor.getSnapshot(); - expect(idleSnapshot.matches('idle')).toBe(true); - expect(idleSnapshot.context.messages).toHaveLength(0); - expect(idleSnapshot.context.reminders).toHaveLength(1); - expect(idleSnapshot.context.reminders[0]?.meta).toEqual({ source: 'reminder', key: 'todo' }); - expect(extractText(idleSnapshot.context.reminders[0]?.message ?? createUserMessage(''))).toContain('stale'); + await vi.waitFor(() => { + expect(store.getState().reminders).toHaveLength(1); + }); + expect(actor.getSnapshot().matches('idle')).toBe(true); + expect(store.getState().history).toHaveLength(0); + expect(store.getState().reminders).toHaveLength(1); + expect(store.getState().reminders[0]?.meta).toEqual({ source: 'reminder', key: 'todo' }); + expect(extractText(store.getState().reminders[0]?.message ?? createUserMessage(''))).toContain('stale'); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); await vi.waitFor(() => { expect(resolveTool).toBeDefined(); }); - expect(actor.getSnapshot().context.reminders).toHaveLength(1); + expect(store.getState().reminders).toHaveLength(1); resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 5, + (s) => s.matches('idle') && store.getState().history.length === 5, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:slow', 'user:\nstale\n', 'assistant:done', ]); - expect(snapshot.context.messages[3]?.meta).toEqual({ source: 'reminder', key: 'todo' }); - expect(snapshot.context.reminders).toHaveLength(0); + expect(store.getState().history[3]?.meta).toEqual({ source: 'reminder', key: 'todo' }); + expect(store.getState().reminders).toHaveLength(0); expect(consumedKeys).toEqual([['todo']]); }); }); @@ -909,10 +977,11 @@ describe('agent machine llm retry', () => { const ticks = [1000, 1100, 1200, 100000, 100100, 100140, 100200]; const timingPlugin = createTimingPlugin({ now: () => ticks.shift() ?? Number.NaN }); const tools: ToolDefinition[] = []; + const store = await testStore(); const actor = createActor( - createTestAgentMachine(tools, requester, { maxAttemptsPerStep: 3 }), + createTestAgentMachine(tools, requester, { maxAttemptsPerStep: 3 }), { - input: { request: { model } }, + input: { request: { model }, store }, }, ); connectPlugins(actor, [timingPlugin]); @@ -923,9 +992,9 @@ describe('agent machine llm retry', () => { actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 2, + (s) => s.matches('idle') && store.getState().history.length === 2, { timeout: 5000 }, ); @@ -938,7 +1007,7 @@ describe('agent machine llm retry', () => { statusCode: 500, }); expect(failures).toHaveLength(0); - expect(rolesAndTexts(snapshot.context.messages)).toEqual(['user:hi', 'assistant:recovered']); + expect(rolesAndTexts(store.getState().history)).toEqual(['user:hi', 'assistant:recovered']); const delayMs = retrying[0]?.delayMs ?? 0; expect(timingPlugin.timing()).toEqual({ @@ -967,8 +1036,9 @@ describe('agent machine input.steer', () => { }), 'slow_tool', ); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -977,24 +1047,30 @@ describe('agent machine input.steer', () => { expect(resolveTool).toBeDefined(); }); actor.send({ type: 'input.submit', id: 'p1', message: createUserMessage('steer me') }); - expect(actor.getSnapshot().context.queue).toHaveLength(1); + await vi.waitFor(() => { + expect(store.getState().queue).toHaveLength(1); + }); actor.send({ type: 'input.steer', id: 'nope' }); - expect(actor.getSnapshot().context.queue).toHaveLength(1); - expect(actor.getSnapshot().context.notifications).toHaveLength(0); + await vi.waitFor(() => { + expect(store.getState().queue).toHaveLength(1); + expect(store.getState().notifications).toHaveLength(0); + }); actor.send({ type: 'input.steer', id: 'p1' }); - expect(actor.getSnapshot().context.queue).toHaveLength(0); - expect(actor.getSnapshot().context.notifications).toHaveLength(1); + await vi.waitFor(() => { + expect(store.getState().queue).toHaveLength(0); + expect(store.getState().notifications).toHaveLength(1); + }); resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 5, + (s) => s.matches('idle') && store.getState().history.length === 5, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:slow', @@ -1028,8 +1104,9 @@ describe('agent machine input.abort', () => { }, }; const tools: ToolDefinition[] = []; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); const aborted: HistoryMessage[][] = []; const aborting: unknown[] = []; @@ -1042,7 +1119,7 @@ describe('agent machine input.abort', () => { expect(signals).toHaveLength(1); }); actor.send({ type: 'input.abort' }); - const snapshot = await waitFor( + await waitFor( actor, (s) => s.matches('idle') && aborted.length === 1, { timeout: 5000 }, @@ -1050,7 +1127,9 @@ describe('agent machine input.abort', () => { expect(signals[0]?.aborted).toBe(true); expect(aborting).toHaveLength(1); - expect(rolesAndTexts(snapshot.context.messages)).toEqual(['user:hi', 'assistant:hello']); + await vi.waitFor(() => { + expect(rolesAndTexts(store.getState().history)).toEqual(['user:hi', 'assistant:hello']); + }); expect(rolesAndTexts(aborted[0] ?? [])).toEqual(['user:hi', 'assistant:hello']); const salvaged = aborted[0]?.[1]; expect(salvaged?.message.role === 'assistant' && salvaged.message.toolCalls).toEqual([]); @@ -1068,8 +1147,9 @@ describe('agent machine input.abort', () => { signal.addEventListener('abort', () => reject(new Error('tool stopped'))); }); }, 'slow_tool'); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -1079,14 +1159,14 @@ describe('agent machine input.abort', () => { }); actor.send({ type: 'input.abort' }); expect(actor.getSnapshot().value).toEqual({ running: 'aborting' }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 3, + (s) => s.matches('idle') && store.getState().history.length === 3, { timeout: 5000 }, ); expect(signals[0]?.aborted).toBe(true); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:aborted', @@ -1104,8 +1184,9 @@ describe('agent machine input.abort', () => { }), 'slow_tool', ); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); const aborted: HistoryMessage[][] = []; actor.on('turn.aborted', (event) => aborted.push(event.messages)); @@ -1116,17 +1197,19 @@ describe('agent machine input.abort', () => { expect(actor.getSnapshot().context.turnTools['call-1']).toBeDefined(); }); actor.send({ type: 'input.abort' }); - const snapshot = await waitFor( + await waitFor( actor, (s) => s.matches('idle') && aborted.length === 1, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ - 'user:hi', - 'assistant:', - 'tool:partial', - ]); + await vi.waitFor(() => { + expect(rolesAndTexts(store.getState().history)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:partial', + ]); + }); }); it('forces the turn to aborted on a second abort when a tool ignores the signal', async () => { @@ -1138,8 +1221,9 @@ describe('agent machine input.abort', () => { signals.push(signal); return new Promise(() => {}); }, 'slow_tool'); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -1151,14 +1235,14 @@ describe('agent machine input.abort', () => { expect(actor.getSnapshot().value).toEqual({ running: 'aborting' }); actor.send({ type: 'input.abort' }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 3, + (s) => s.matches('idle') && store.getState().history.length === 3, { timeout: 5000 }, ); expect(signals[0]?.aborted).toBe(true); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:aborted', @@ -1170,8 +1254,9 @@ describe('agent machine input.abort', () => { createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), ]); const tools = stubTools(() => new Promise(() => {}), 'slow_tool'); - const actor = createActor(createTestAgentMachine(tools, requester, undefined, 50), { - input: { request: { model } }, + const store = await testStore(); + const actor = createActor(createTestAgentMachine(tools, requester, undefined, 50), { + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -1181,13 +1266,13 @@ describe('agent machine input.abort', () => { }); actor.send({ type: 'input.abort' }); expect(actor.getSnapshot().value).toEqual({ running: 'aborting' }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 3, + (s) => s.matches('idle') && store.getState().history.length === 3, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'assistant:', 'tool:aborted', @@ -1215,8 +1300,9 @@ describe('agent machine input.abort', () => { }, }; const tools: ToolDefinition[] = []; + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -1228,13 +1314,13 @@ describe('agent machine input.abort', () => { actor.send({ type: 'input.submit', id: 'p2', message: createUserMessage('steered') }); actor.send({ type: 'input.steer', id: 'p2' }); actor.send({ type: 'input.abort' }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 4, + (s) => s.matches('idle') && store.getState().history.length === 4, { timeout: 5000 }, ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:hi', 'user:steered', 'user:queued', @@ -1272,8 +1358,9 @@ describe('agent machine input.abort', () => { detach?.({ text: 'async running: bg_tool' }); return new Promise(() => {}); }, 'bg_tool'); + const store = await testStore(); const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); @@ -1311,13 +1398,14 @@ describe('agent machine max steps', () => { }), 'ok_tool', ); + const store = await testStore(); const actor = createActor( createAgentMachine({ tools, - turnActor: createTurnMachine(createLlmMachine({ requester })), + turnActor: createTurnMachine(requester), maxStepsPerTurn: 2, }), - { input: { request: { model } } }, + { input: { request: { model }, store } }, ); const failures: Extract[] = []; actor.on('turn.failed', (event) => failures.push(event)); @@ -1349,7 +1437,7 @@ describe('agent machine max steps', () => { }); resolvers[3]?.(); - const snapshot = await waitFor(actor, (s) => s.matches('idle') && failures.length === 1, { + await waitFor(actor, (s) => s.matches('idle') && failures.length === 1, { timeout: 5000, }); @@ -1358,29 +1446,39 @@ describe('agent machine max steps', () => { expect(failures[0]?.error).toBeInstanceOf(MaxStepsExceededError); expect((failures[0]?.error as MaxStepsExceededError).code).toBe('loop.max_steps_exceeded'); expect((failures[0]?.error as MaxStepsExceededError).details).toEqual({ maxSteps: 2 }); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ - 'user:hi', - 'assistant:', - 'tool:ok', - 'assistant:', - 'tool:ok', - 'user:keep going', - 'assistant:', - 'tool:ok', - 'assistant:', - 'tool:ok', - ]); + await vi.waitFor(() => { + expect(rolesAndTexts(store.getState().history)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:ok', + 'assistant:', + 'tool:ok', + 'user:keep going', + 'assistant:', + 'tool:ok', + 'assistant:', + 'tool:ok', + ]); + }); expect(actor.getSnapshot().status).toBe('active'); }); }); describe('agent machine context reset', () => { - it('replaces messages, turnId and branchId when idle', async () => { + it('replaces messages and branchId when idle without rewinding the turn clock', async () => { const requester = createStubRequester([ createAssistantMessage([{ type: 'text', text: 'reply' }]), ]); + const backend = new MemoryBackend(); + const treeStore = await TreeStore.open(backend, {}); + const tree = await treeStore.tree('test'); + tree.createBranch('main'); + const store = await createEventStore({ + journal: journalFromBranch(tree.openBranch('main'), tree), + slices: agentSlices, + }); const actor = createActor(createTestAgentMachine([], requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); const resets: string[] = []; @@ -1395,41 +1493,41 @@ describe('agent machine context reset', () => { }); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 2, { + await waitFor(actor, (s) => s.matches('idle') && store.getState().history.length === 2, { timeout: 5000, }); - actor.send({ - type: 'context.reset', - history: [createUserEntry(createUserMessage('seed'), { source: 'input' })], - turnId: 0, - branchId: 'main~2', - }); + await seedBranch(tree, 'main~2', ['seed']); + await store.reset(journalFromBranch(tree.openBranch('main~2'), tree)); - expect(actor.getSnapshot().context.messages).toHaveLength(1); - expect(actor.getSnapshot().context.turnId).toBe(0); - expect(actor.getSnapshot().context.branchId).toBe('main~2'); - expect(resets).toEqual(['main~2']); + await vi.waitFor(() => { + expect(resets).toEqual(['main~2']); + }); + expect(store.ref.branch).toBe('main~2'); + expect(store.getState().history).toHaveLength(1); + expect(store.getState().turnIndex.nextTurnId).toBe(1); actor.send({ type: 'input.submit', message: createUserMessage('again') }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 3, + (s) => s.matches('idle') && store.getState().history.length === 3, { timeout: 5000 }, ); - expect(snapshot.context.turnId).toBe(1); + expect(store.getState().turnIndex.nextTurnId).toBe(2); expect(turnStarts).toEqual([ - { turnId: 1, branchId: 'main' }, + { turnId: 0, branchId: 'main' }, { turnId: 1, branchId: 'main~2' }, ]); }); - it('ignores context.reset while running, including a turn driven by notify', async () => { + it('aborts an in-flight turn and applies the new state on store.reset while running', async () => { let calls = 0; + const signals: AbortSignal[] = []; const releases: Array<() => void> = []; const requester: LlmRequester = { - generate: (_config, _content, { onEvent }) => { + generate: (_config, _content, { signal, onEvent }) => { calls += 1; + signals.push(signal); return new Promise((resolve) => { releases.push(() => { onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: 'late' } }); @@ -1439,37 +1537,46 @@ describe('agent machine context reset', () => { }); }, }; + const backend = new MemoryBackend(); + const treeStore = await TreeStore.open(backend, {}); + const tree = await treeStore.tree('test'); + tree.createBranch('main'); + const store = await createEventStore({ + journal: journalFromBranch(tree.openBranch('main'), tree), + slices: agentSlices, + }); const actor = createActor(createTestAgentMachine([], requester), { - input: { request: { model } }, + input: { request: { model }, store }, }); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('hi') }); await vi.waitFor(() => expect(calls).toBe(1)); - actor.send({ type: 'context.reset', history: [], turnId: 0, branchId: 'other' }); - expect(actor.getSnapshot().context.branchId).toBe('main'); + await seedBranch(tree, 'other', ['seeded']); + await store.reset(journalFromBranch(tree.openBranch('other'), tree)); - releases[0]?.(); - await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 2, { + await waitFor(actor, (s) => s.matches('idle') && store.ref.branch === 'other', { timeout: 5000, }); + expect(signals[0]?.aborted).toBe(true); + expect(rolesAndTexts(store.getState().history)).toEqual(['user:seeded']); + + releases[0]?.(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(rolesAndTexts(store.getState().history)).toEqual(['user:seeded']); actor.send({ type: 'input.notify', message: createUserMessage('note') }); await vi.waitFor(() => expect(calls).toBe(2)); - actor.send({ type: 'context.reset', history: [], turnId: 0, branchId: 'other' }); - expect(actor.getSnapshot().context.branchId).toBe('main'); + + await seedBranch(tree, 'third', ['third-seed']); + await store.reset(journalFromBranch(tree.openBranch('third'), tree)); + + await waitFor(actor, (s) => s.matches('idle') && store.ref.branch === 'third', { + timeout: 5000, + }); + expect(signals[1]?.aborted).toBe(true); + expect(rolesAndTexts(store.getState().history)).toEqual(['user:third-seed']); releases[1]?.(); - const snapshot = await waitFor( - actor, - (s) => s.matches('idle') && s.context.messages.length === 4, - { timeout: 5000 }, - ); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ - 'user:hi', - 'assistant:late', - 'user:note', - 'assistant:late', - ]); }); }); diff --git a/packages/agent-core-v2/src/human/test/agent/persist.test.ts b/packages/agent-core-v2/src/human/test/agent/persist.test.ts deleted file mode 100644 index c084d8b0374..00000000000 --- a/packages/agent-core-v2/src/human/test/agent/persist.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createActor, waitFor } from 'xstate'; - -import { collectPluginTools, connectPlugins } from '#/plugin'; -import { UNKNOWN_CAPABILITY } from '#/llm/capability'; -import { - createAssistantMessage, - createUserMessage, - type AssistantMessage, - type Message, - type ToolCall, -} from '#/llm/message'; -import type { LlmModel } from '#/llm/model'; -import { createLlmMachine } from '#/llm/requester/machine'; -import type { LlmRequester } from '#/llm/requester/requester'; -import { createAgentMachine } from '#/agent/machine'; -import { loadAgentState, type TurnEntryData } from '#/agent/replay'; -import { persistAgent } from '#/persist/agent'; -import { createTurnMachine } from '#/agent/turn'; -import { MemoryBackend } from '#/store/backend/memory'; -import type { Branch } from '#/store/branch'; -import { TreeStore } from '#/store/store'; -import type { EntryLine } from '#/store/types'; -import { createTodoPlugin } from '#/todo/plugin'; -import { restoreTodoState, snapshotTodoState } from '#/todo/state'; -import { defineTool, type ToolDefinition } from '#/tool/tool'; - -const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; - -function toolCall(id: string, name: string, args: string = '{}'): ToolCall { - return { type: 'function', id, name, arguments: args }; -} - -function createStubRequester(responses: readonly AssistantMessage[]): LlmRequester { - let call = 0; - return { - generate: (_config, _content, { onEvent }) => { - const message = responses[Math.min(call, responses.length - 1)] as AssistantMessage; - call += 1; - for (const part of [...message.content, ...message.toolCalls]) { - onEvent?.({ type: 'llm.streaming.part', part }); - } - onEvent?.({ type: 'llm.done' }); - return Promise.resolve(); - }, - }; -} - -function createTestAgentMachine(tools: readonly ToolDefinition[], requester: LlmRequester) { - return createAgentMachine({ - tools, - turnActor: createTurnMachine(createLlmMachine({ requester })), - }); -} - -const okTool = defineTool({ - name: 'ok_tool', - description: 'stub ok tool', - parameters: { type: 'object', properties: {} }, - execute: () => Promise.resolve({ content: [{ type: 'text', text: 'ok' }] }), -}); - -async function branchOf(store: TreeStore, tree: string, branch: string): Promise { - const loaded = await store.tree(tree); - return loaded.has(branch) ? loaded.openBranch(branch) : loaded.createBranch(branch); -} - -async function entriesOf(store: TreeStore, tree: string, branch: string): Promise { - const loaded = await store.tree(tree); - return [...loaded.openBranch(branch).walk()].toReversed(); -} - -function entryTypes(entries: readonly EntryLine[]): string[] { - return entries.map((entry) => entry.type); -} - -function turnData(entry: EntryLine): TurnEntryData { - return entry.payload.data as TurnEntryData; -} - -async function waitForEntries( - store: TreeStore, - tree: string, - branch: string, - count: number, -): Promise { - let entries: EntryLine[] = []; - await vi.waitFor(async () => { - entries = await entriesOf(store, tree, branch); - expect(entries).toHaveLength(count); - }); - return entries; -} - -describe('persistAgent', () => { - it('persists messages and turn boundaries in order', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const requester = createStubRequester([ - createAssistantMessage([], [toolCall('call-1', 'ok_tool')]), - createAssistantMessage([{ type: 'text', text: 'done' }]), - ]); - const actor = createActor(createTestAgentMachine([okTool], requester), { - input: { request: { model } }, - }); - actor.start(); - const handle = persistAgent(actor, await branchOf(store, 'session', 'main')); - actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - - const entries = await waitForEntries(store, 'session', 'main', 6); - expect(entryTypes(entries)).toEqual([ - 'message', - 'turn', - 'message', - 'message', - 'message', - 'turn', - ]); - expect((entries[0] as EntryLine).payload.data).toMatchObject({ - message: { role: 'user' }, - meta: { source: 'input' }, - }); - expect(turnData(entries[1] as EntryLine)).toEqual({ phase: 'start', turnId: 1 }); - expect(turnData(entries[5] as EntryLine)).toEqual({ - phase: 'end', - turnId: 1, - outcome: 'done', - }); - handle.dispose(); - }); - - it('restores messages and turnId without duplicating entries', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const first = createStubRequester([createAssistantMessage([{ type: 'text', text: 'first' }])]); - const firstActor = createActor(createTestAgentMachine([okTool], first), { - input: { request: { model } }, - }); - firstActor.start(); - persistAgent(firstActor, await branchOf(store, 'session', 'main')); - firstActor.send({ type: 'input.submit', message: createUserMessage('hi') }); - const firstSnapshot = await waitFor( - firstActor, - (s) => s.matches('idle') && s.context.messages.length === 2, - { timeout: 5000 }, - ); - await waitForEntries(store, 'session', 'main', 4); - - const reopened = await TreeStore.open(fs); - const loaded = await loadAgentState(await reopened.tree('session'), 'main'); - expect(loaded.messages).toEqual(firstSnapshot.context.messages); - expect(loaded.turnId).toBe(1); - - const second = createStubRequester([createAssistantMessage([{ type: 'text', text: 'second' }])]); - const secondActor = createActor(createTestAgentMachine([okTool], second), { - input: { request: { model }, history: loaded.messages, turnId: loaded.turnId }, - }); - secondActor.start(); - persistAgent(secondActor, await branchOf(reopened, 'session', 'main')); - secondActor.send({ type: 'input.submit', message: createUserMessage('again') }); - const secondSnapshot = await waitFor( - secondActor, - (s) => s.matches('idle') && s.context.messages.length === 4, - { timeout: 5000 }, - ); - - const entries = await waitForEntries(reopened, 'session', 'main', 8); - const messageEntries = entries.filter((entry) => entry.type === 'message'); - expect(messageEntries.map((entry) => entry.payload.data)).toEqual( - secondSnapshot.context.messages, - ); - const starts = entries - .filter((entry) => entry.type === 'turn') - .map((entry) => turnData(entry)) - .filter((data) => data.phase === 'start'); - expect(starts).toEqual([ - { phase: 'start', turnId: 1 }, - { phase: 'start', turnId: 2 }, - ]); - }); - - it('persists and restores todo state', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const todo = createTodoPlugin(); - const tools = collectPluginTools([todo]); - const requester = createStubRequester([ - createAssistantMessage( - [], - [ - toolCall( - 'call-todo', - 'TodoList', - JSON.stringify({ todos: [{ title: 'task a', status: 'in_progress' }] }), - ), - ], - ), - createAssistantMessage([{ type: 'text', text: 'done' }]), - ]); - const actor = createActor(createTestAgentMachine(tools, requester), { - input: { request: { model } }, - }); - connectPlugins(actor, [todo]); - actor.start(); - persistAgent(actor, await branchOf(store, 'session', 'main'), { - states: { todo: () => snapshotTodoState(todo.state) }, - }); - actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - - const entries = await waitForEntries(store, 'session', 'main', 7); - expect(entryTypes(entries)).toEqual([ - 'message', - 'turn', - 'state', - 'message', - 'message', - 'message', - 'turn', - ]); - const stateEntry = entries.find((entry) => entry.type === 'state') as EntryLine; - expect(stateEntry.payload.data).toEqual({ - name: 'todo', - value: { todos: [{ title: 'task a', status: 'in_progress' }], lastWriteTurn: 1 }, - }); - - const reopened = await TreeStore.open(fs); - const loaded = await loadAgentState(await reopened.tree('session'), 'main'); - const restored = restoreTodoState(loaded.states['todo'], loaded.turnId); - expect(restored.todos).toEqual([{ title: 'task a', status: 'in_progress' }]); - expect(restored.lastWriteTurn).toBe(1); - expect(restored.currentTurn).toBe(1); - }); - - it('restores offloaded messages through blob refs', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs, { offloadThreshold: 1 }); - const requester = createStubRequester([ - createAssistantMessage([{ type: 'text', text: 'a fairly long reply' }]), - ]); - const actor = createActor(createTestAgentMachine([okTool], requester), { - input: { request: { model } }, - }); - actor.start(); - persistAgent(actor, await branchOf(store, 'session', 'main')); - actor.send({ type: 'input.submit', message: createUserMessage('hi') }); - const snapshot = await waitFor( - actor, - (s) => s.matches('idle') && s.context.messages.length === 2, - { timeout: 5000 }, - ); - await waitForEntries(store, 'session', 'main', 4); - - const reopened = await TreeStore.open(fs); - const loaded = await loadAgentState(await reopened.tree('session'), 'main'); - expect(loaded.messages).toEqual(snapshot.context.messages); - expect(loaded.turnId).toBe(1); - }); -}); diff --git a/packages/agent-core-v2/src/human/test/agent/turn.test.ts b/packages/agent-core-v2/src/human/test/agent/turn.test.ts index dc91094f2c1..acae1bc8440 100644 --- a/packages/agent-core-v2/src/human/test/agent/turn.test.ts +++ b/packages/agent-core-v2/src/human/test/agent/turn.test.ts @@ -6,9 +6,9 @@ import type { LlmErrorMessage } from '#/llm/errors'; import type { ContentPart, Message, UserMessage } from '#/llm/message'; import { createMediaDegradeRecovery } from '#/llm/media/degrade'; import type { LlmModel } from '#/llm/model'; -import { createLlmMachine, type LlmEvent } from '#/llm/requester/machine'; +import { createRequestActor, type LlmEvent } from '#/llm/requester/actor'; import type { LlmRecovery } from '#/llm/requester/recovery'; -import type { LlmRequester } from '#/llm/requester/requester'; +import type { LlmCredentialProvider, LlmRequester } from '#/llm/requester/requester'; import type { LlmRetryOptions } from '#/llm/requester/retry'; import { createTurnMachine, @@ -92,7 +92,7 @@ function startTurnActor( events: {} as TurnEvent, emitted: {} as TurnLlmEvent, }, - actors: { turn: createTurnMachine(createLlmMachine({ requester }), options) }, + actors: { turn: createTurnMachine(requester, options) }, }).createMachine({ id: 'harness', initial: 'running', @@ -132,6 +132,44 @@ function startTurnActor( return { actor, retrying, recovering, sent, failed }; } +function startRequestActor(requester: LlmRequester, signal: AbortSignal) { + const harness = setup({ + types: { + input: {} as { signal: AbortSignal }, + context: {} as { signal: AbortSignal }, + events: {} as LlmEvent, + emitted: {} as LlmEvent, + }, + actors: { request: createRequestActor(requester) }, + }).createMachine({ + id: 'request-harness', + initial: 'running', + context: ({ input }) => input, + states: { + running: { + invoke: { + src: 'request', + input: ({ context }) => ({ + config: { model }, + content: { messages: [] }, + signal: context.signal, + }), + }, + on: { + '*': { + actions: emit(({ event }) => event), + }, + }, + }, + }, + }); + const failed: unknown[] = []; + const actor = createActor(harness, { input: { signal } }); + actor.on('llm.failed.remote', (event) => failed.push(event)); + actor.start(); + return { failed }; +} + async function flush(): Promise { await vi.advanceTimersByTimeAsync(0); } @@ -534,3 +572,177 @@ describe('turn machine media recovery', () => { expect(recovering).toHaveLength(0); }); }); + +describe('turn machine credential recovery', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createCredentials(onInvalidate: () => void): { + provider: LlmCredentialProvider; + tokens: readonly string[]; + } { + const tokens = ['tok-1', 'tok-2']; + let resolutions = 0; + return { + tokens, + provider: { + resolve: () => { + const apiKey = tokens[Math.min(resolutions, tokens.length - 1)] as string; + resolutions += 1; + return { apiKey }; + }, + canRecover: (error) => + typeof error === 'object' && + error !== null && + (error as { statusCode?: number }).statusCode === 401, + invalidate: onInvalidate, + }, + }; + } + + it('refreshes credentials once on a recoverable 401 and retries', async () => { + let invalidations = 0; + const { provider } = createCredentials(() => (invalidations += 1)); + const apiKeys: (string | undefined)[] = []; + const requester: LlmRequester = { + generate: (config, _content, control) => { + apiKeys.push(config.model.apiKey); + control.onEvent?.({ type: 'llm.sent' }); + if (apiKeys.length === 1) { + control.onEvent?.({ type: 'llm.failed.remote', error: statusError(401, 'unauthorized') }); + return Promise.resolve(); + } + control.onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: 'done' } }); + control.onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; + const { actor, recovering, sent, failed } = startTurnActor(requester, undefined, { + request: { model, credentials: provider }, + }); + + await drain(); + + expect(apiKeys).toEqual(['tok-1', 'tok-2']); + expect(invalidations).toBe(1); + expect(recovering).toHaveLength(1); + expect(recovering[0]).toMatchObject({ + strategy: 'credentials', + action: 'refresh', + statusCode: 401, + }); + expect(sent.map((event) => event.recovery?.action)).toEqual([undefined, 'refresh']); + expect(actor.getSnapshot().context.turnOutput).toMatchObject({ type: 'done' }); + expect(failed).toHaveLength(0); + }); + + it('keeps recovered messages when a credential refresh follows a message recovery', async () => { + let invalidations = 0; + const { provider } = createCredentials(() => (invalidations += 1)); + const { requester, calls, seen } = createCapturingRequester([ + tooLargeError(), + statusError(401, 'unauthorized'), + 'ok', + ]); + const { actor, recovering } = startTurnActor( + requester, + { recovery: createMediaDegradeRecovery() }, + { + ...mediaHistory([mediaMessage('a', 2), mediaMessage('b', 1), mediaMessage('c', 1)]), + request: { model, credentials: provider }, + }, + ); + + await drain(); + + expect(calls()).toBe(3); + expect(invalidations).toBe(1); + expect(recovering.map((event) => `${event.strategy}:${event.action}`)).toEqual([ + 'media-degrade:degraded', + 'credentials:refresh', + ]); + expect(countImageParts(seen[1] ?? [])).toBe(2); + expect(countImageParts(seen[2] ?? [])).toBe(2); + expect(actor.getSnapshot().context.turnOutput).toMatchObject({ type: 'done' }); + }); + + it('fails when the attempt after a credential refresh also fails', async () => { + let invalidations = 0; + const { provider } = createCredentials(() => (invalidations += 1)); + const { requester, calls } = createStubRequester([ + statusError(401, 'unauthorized'), + statusError(401, 'still unauthorized'), + ]); + const { actor, recovering, failed } = startTurnActor(requester, undefined, { + request: { model, credentials: provider }, + }); + + await drain(); + + expect(calls()).toBe(2); + expect(invalidations).toBe(1); + expect(recovering).toHaveLength(1); + expect(actor.getSnapshot().context.turnOutput).toMatchObject({ type: 'failed' }); + expect(failed).toHaveLength(1); + }); + + it('does not refresh when the request carries no recoverable credentials', async () => { + const { requester, calls } = createStubRequester([statusError(401, 'unauthorized')]); + const { actor, recovering, failed } = startTurnActor(requester); + + await drain(); + + expect(calls()).toBe(1); + expect(recovering).toHaveLength(0); + expect(actor.getSnapshot().context.turnOutput).toMatchObject({ type: 'failed' }); + expect(failed).toHaveLength(1); + }); + + it('fails the turn instead of hanging when credential resolution rejects', async () => { + const { requester, calls } = createStubRequester(['ok']); + const provider: LlmCredentialProvider = { + resolve: () => Promise.reject(new Error('login required')), + }; + const { actor, failed } = startTurnActor(requester, undefined, { + request: { model, credentials: provider }, + }); + + await drain(); + + expect(calls()).toBe(0); + expect(actor.getSnapshot().context.turnOutput).toMatchObject({ type: 'failed' }); + expect(failed).toHaveLength(1); + expect((failed[0] as { message?: string }).message).toContain('login required'); + }); + + it('does not report llm.failed.remote when the request aborts', async () => { + const requester: LlmRequester = { + generate: () => Promise.reject(new DOMException('The operation was aborted.', 'AbortError')), + }; + const { failed } = startRequestActor(requester, new AbortController().signal); + + await drain(); + + expect(failed).toHaveLength(0); + }); + + it('does not report llm.failed.remote when the signal is already aborted', async () => { + const controller = new AbortController(); + const requester: LlmRequester = { + generate: () => { + controller.abort(); + return Promise.reject(new Error('boom')); + }, + }; + const { failed } = startRequestActor(requester, controller.signal); + + await drain(); + + expect(failed).toHaveLength(0); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/credentials/credentials.test.ts b/packages/agent-core-v2/src/human/test/credentials/credentials.test.ts new file mode 100644 index 00000000000..aa83192b6aa --- /dev/null +++ b/packages/agent-core-v2/src/human/test/credentials/credentials.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyCredential, + credentialsRecovery, + oauthCredentials, + resolveModelCredentials, + staticCredentials, +} from '#/credentials/credentials'; +import type { LlmModel } from '#/llm/model'; +import type { LlmRecoveryContext, LlmRecoveryRecord } from '#/llm/requester/recovery'; +import type { LlmCredentialProvider } from '#/llm/requester/requester'; + +const MODEL: LlmModel = { + provider: 'fake', + model: 'fake-model', + apiKey: 'base-key', + defaultHeaders: { 'x-base': '1' }, +}; + +describe('staticCredentials', () => { + it('resolves the static api key and never recovers', async () => { + const provider = staticCredentials('sk-1'); + expect(await provider.resolve()).toEqual({ apiKey: 'sk-1' }); + expect(provider.canRecover).toBeUndefined(); + expect(provider.invalidate).toBeUndefined(); + }); + + it('resolves undefined for missing or blank keys', async () => { + expect(await staticCredentials(undefined).resolve()).toBeUndefined(); + expect(await staticCredentials(' ').resolve()).toBeUndefined(); + }); +}); + +describe('oauthCredentials', () => { + it('refreshes with force on invalidate and consumes the refresh on the next resolve', async () => { + const calls: (boolean | undefined)[] = []; + const provider = oauthCredentials((options) => { + calls.push(options?.force); + return Promise.resolve('tok'); + }); + + await provider.resolve(); + await provider.resolve(); + provider.invalidate?.(); + await provider.resolve(); + await provider.resolve(); + + expect(calls).toEqual([undefined, undefined, true, undefined]); + }); + + it('starts the forced refresh eagerly on invalidate, before the next resolve', async () => { + const calls: (boolean | undefined)[] = []; + const provider = oauthCredentials((options) => { + calls.push(options?.force); + return Promise.resolve('tok'); + }); + + provider.invalidate?.(); + + expect(calls).toEqual([true]); + + await provider.resolve(); + + expect(calls).toEqual([true]); + }); + + it('coalesces repeated invalidates into a single refresh', async () => { + const calls: (boolean | undefined)[] = []; + const provider = oauthCredentials((options) => { + calls.push(options?.force); + return Promise.resolve('tok'); + }); + + provider.invalidate?.(); + provider.invalidate?.(); + await provider.resolve(); + + expect(calls).toEqual([true]); + }); + + it('propagates a failed refresh to the consuming resolve and recovers afterwards', async () => { + let calls = 0; + const provider = oauthCredentials(() => { + calls += 1; + return calls === 1 ? Promise.reject(new Error('login required')) : Promise.resolve('tok'); + }); + + provider.invalidate?.(); + + await expect(provider.resolve()).rejects.toThrow('login required'); + await expect(provider.resolve()).resolves.toEqual({ apiKey: 'tok' }); + }); + + it('recovers only from 401 errors', () => { + const provider = oauthCredentials(() => Promise.resolve('tok')); + expect(provider.canRecover?.(Object.assign(new Error('x'), { status: 401 }))).toBe(true); + expect(provider.canRecover?.(Object.assign(new Error('x'), { statusCode: 401 }))).toBe(true); + expect(provider.canRecover?.(Object.assign(new Error('x'), { statusCode: 403 }))).toBe(false); + expect(provider.canRecover?.(new Error('boom'))).toBe(false); + }); + + it('resolves undefined when the token source has no token', async () => { + const provider = oauthCredentials(() => Promise.resolve(undefined)); + await expect(provider.resolve()).resolves.toBeUndefined(); + }); +}); + +describe('applyCredential / resolveModelCredentials', () => { + it('returns the model unchanged when the credential is undefined', async () => { + expect(applyCredential(MODEL, undefined)).toBe(MODEL); + await expect(resolveModelCredentials(MODEL, undefined)).resolves.toBe(MODEL); + }); + + it('overrides the api key and merges headers', () => { + const applied = applyCredential(MODEL, { apiKey: 'fresh', headers: { 'x-auth': 't' } }); + expect(applied.apiKey).toBe('fresh'); + expect(applied.defaultHeaders).toEqual({ 'x-base': '1', 'x-auth': 't' }); + }); + + it('keeps the model api key when the credential carries none', () => { + const applied = applyCredential(MODEL, { headers: { 'x-auth': 't' } }); + expect(applied.apiKey).toBe('base-key'); + }); +}); + +function recoveryContext( + error: unknown, + applied: readonly LlmRecoveryRecord[] = [], + credentials?: LlmCredentialProvider, +): LlmRecoveryContext { + return { error: error as LlmRecoveryContext['error'], messages: [], applied, credentials }; +} + +const unauthorized = Object.assign(new Error('unauthorized'), { status: 401 }); +const forbidden = Object.assign(new Error('forbidden'), { status: 403 }); + +describe('credentialsRecovery', () => { + it('proposes a credentials refresh on a recoverable error', () => { + const provider = oauthCredentials(() => Promise.resolve('tok')); + expect(credentialsRecovery.propose(recoveryContext(unauthorized, [], provider))).toEqual({ + action: 'refresh', + refreshCredentials: true, + }); + }); + + it('does not propose when the strategy was already applied', () => { + const provider = oauthCredentials(() => Promise.resolve('tok')); + const applied: LlmRecoveryRecord[] = [{ strategy: 'credentials', action: 'refresh' }]; + expect(credentialsRecovery.propose(recoveryContext(unauthorized, applied, provider))).toBeUndefined(); + }); + + it('does not propose without recoverable credentials', () => { + expect(credentialsRecovery.propose(recoveryContext(unauthorized))).toBeUndefined(); + expect( + credentialsRecovery.propose(recoveryContext(unauthorized, [], staticCredentials('sk-1'))), + ).toBeUndefined(); + expect( + credentialsRecovery.propose( + recoveryContext(forbidden, [], oauthCredentials(() => Promise.resolve('tok'))), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/credentials/kimi-oauth.test.ts b/packages/agent-core-v2/src/human/test/credentials/kimi-oauth.test.ts new file mode 100644 index 00000000000..25bc2b0069c --- /dev/null +++ b/packages/agent-core-v2/src/human/test/credentials/kimi-oauth.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { kimiOAuthCredentialProvider } from '#/credentials/kimi-oauth'; + +describe('kimiOAuthCredentialProvider', () => { + function createTokens() { + const calls: (boolean | undefined)[] = []; + return { + calls, + tokens: { + getAccessToken: (options?: { readonly force?: boolean }) => { + calls.push(options?.force); + return Promise.resolve('access-token'); + }, + }, + }; + } + + it('resolves the access token from the token provider', async () => { + const { calls, tokens } = createTokens(); + const provider = kimiOAuthCredentialProvider(tokens); + + await expect(provider.resolve()).resolves.toEqual({ apiKey: 'access-token' }); + expect(calls).toEqual([undefined]); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/eventStore/eventStore.test.ts b/packages/agent-core-v2/src/human/test/eventStore/eventStore.test.ts new file mode 100644 index 00000000000..cb7b505ad84 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/eventStore/eventStore.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { storeActor } from '#/eventStore/actor'; +import { createEventStore, type Cause } from '#/eventStore/eventStore'; +import { defineEvent } from '#/eventStore/events'; +import { journalFromBranch } from '#/eventStore/journal'; +import { createSlice } from '#/eventStore/slice'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; +import type { Tree } from '#/store/tree'; +import { createActor, waitFor } from '#/xstate2'; + +const counterAdded = defineEvent({ type: 'test.counter_added', schema: z.object({ amount: z.number() }) }); +type CounterAdded = ReturnType; + +const noteTagged = defineEvent({ type: 'test.note_tagged', schema: z.object({ tag: z.string() }) }); +type NoteTagged = ReturnType; + +const counterSlice = createSlice({ + name: 'counter', + initialState: () => 0, + reducers: { + 'test.counter_added': (draft, event: CounterAdded) => draft + event.amount, + 'test.counter_bumped_internal': (draft) => draft + 100, + }, +}); + +const notesSlice = createSlice({ + name: 'notes', + initialState: () => [] as string[], + reducers: { + 'test.note_tagged': (draft, event: NoteTagged) => { + draft.push(event.tag); + }, + }, +}); + +const slices = { counter: counterSlice, notes: notesSlice }; + +async function openTree(backend: MemoryBackend = new MemoryBackend()): Promise { + const store = await TreeStore.open(backend, {}); + return store.tree('test'); +} + +async function openJournal(tree: Tree, branch = 'main') { + if (!tree.has(branch)) tree.createBranch(branch); + return journalFromBranch(tree.openBranch(branch), tree); +} + +async function openStore(tree: Tree, opts?: { drainLimit?: number; extraSlices?: Record }) { + const journal = await openJournal(tree); + return createEventStore({ journal, slices, drainLimit: opts?.drainLimit }); +} + +describe('createEventStore', () => { + it('folds dispatched events and refolds them on reopen', async () => { + const tree = await openTree(); + const store = await openStore(tree); + await store.dispatch(counterAdded({ amount: 3 })); + await store.dispatch(noteTagged({ tag: 'a' })); + await store.flush(); + + expect(store.getState()).toEqual({ counter: 3, notes: ['a'] }); + + const reopened = await openStore(tree); + expect(reopened.getState()).toEqual({ counter: 3, notes: ['a'] }); + }); + + it('ignores legacy snapshot entries when folding', async () => { + const tree = await openTree(); + const journal = await openJournal(tree); + await journal.append({ type: 'snapshot', kind: 'snapshot', data: { slices: { counter: 41 } } }); + await journal.append({ + type: 'test.counter_added', + kind: 'event', + data: { type: 'test.counter_added', time: 1, amount: 1 }, + }); + const store = await createEventStore({ journal, slices }); + expect(store.getState()).toEqual({ counter: 1, notes: [] }); + }); + + it('skips unknown event types when folding', async () => { + const tree = await openTree(); + const journal = await openJournal(tree); + await journal.append({ type: 'test.unknown_event', kind: 'event', data: { type: 'test.unknown_event' } }); + await journal.append({ + type: 'test.counter_added', + kind: 'event', + data: { type: 'test.counter_added', time: 1, amount: 5 }, + }); + const store = await createEventStore({ journal, slices }); + expect(store.slice('counter')).toBe(5); + }); +}); + +describe('dispatch', () => { + it('rejects unregistered events and schema-invalid events', async () => { + const tree = await openTree(); + const store = await openStore(tree); + await expect( + store.dispatch({ type: 'test.ghost_event', time: 1, amount: 1 }), + ).rejects.toMatchObject({ + code: 'unregistered-event', + }); + await expect(store.dispatch(counterAdded({ amount: 'x' as unknown as number }))).rejects.toMatchObject({ + code: 'schema', + }); + expect(store.slice('counter')).toBe(0); + }); + + it('serializes concurrent dispatches in seq order', async () => { + const tree = await openTree(); + const store = await openStore(tree); + const [a, b] = await Promise.all([ + store.dispatch(counterAdded({ amount: 1 })), + store.dispatch(counterAdded({ amount: 2 })), + ]); + expect(a.seq).toBe(0); + expect(b.seq).toBe(1); + expect(store.slice('counter')).toBe(3); + }); + + it('reads back appended entries through the journal', async () => { + const tree = await openTree(); + const store = await openStore(tree); + await store.dispatch(noteTagged({ tag: 'x' })); + await store.flush(); + const records = []; + for await (const record of (await openJournal(tree)).read()) records.push(record); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ branch: 'main', seq: 0, type: 'test.note_tagged', kind: 'event' }); + }); +}); + +describe('internal events', () => { + it('folds raised internal events across slices without persisting them', async () => { + const tree = await openTree(); + const raiserSlice = createSlice({ + name: 'raiser', + initialState: () => 0, + reducers: { + 'test.counter_added': (draft, event: CounterAdded, ctx) => { + ctx.enqueue.raise({ type: 'test.counter_bumped_internal' }); + return draft + event.amount; + }, + }, + }); + const journal = await openJournal(tree); + const store = await createEventStore({ journal, slices: { counter: counterSlice, raiser: raiserSlice } }); + await store.dispatch(counterAdded({ amount: 5 })); + expect(store.getState()).toEqual({ counter: 105, raiser: 5 }); + + const records = []; + for await (const record of journal.read()) records.push(record); + expect(records).toHaveLength(1); + + const reopened = await createEventStore({ journal, slices: { counter: counterSlice, raiser: raiserSlice } }); + expect(reopened.getState()).toEqual({ counter: 105, raiser: 5 }); + }); + + it('enforces the drain limit', async () => { + const tree = await openTree(); + const loopSlice = createSlice({ + name: 'loop', + initialState: () => 0, + reducers: { + 'test.counter_added': (draft, _event, ctx) => { + ctx.enqueue.raise({ type: 'test.counter_bumped_internal' }); + return draft + 1; + }, + 'test.counter_bumped_internal': (draft, _event, ctx) => { + ctx.enqueue.raise({ type: 'test.counter_bumped_internal' }); + return draft + 1; + }, + }, + }); + const journal = await openJournal(tree); + const store = await createEventStore({ journal, slices: { loop: loopSlice }, drainLimit: 10 }); + await expect(store.dispatch(counterAdded({ amount: 1 }))).rejects.toMatchObject({ + code: 'drain-limit', + }); + }); +}); + +describe('registerSlice', () => { + it('folds history for late-joined slices and notifies slice-joined', async () => { + const tree = await openTree(); + const journal = await openJournal(tree); + const store = await createEventStore({ journal, slices: { counter: counterSlice } }); + await store.dispatch(counterAdded({ amount: 7 })); + await store.dispatch(noteTagged({ tag: 'late' })); + + const causes: Cause[] = []; + store.subscribe((_state, cause) => causes.push(cause)); + await store.registerSlice(notesSlice); + expect(store.getState()).toEqual({ counter: 7, notes: ['late'] }); + expect(causes).toEqual([{ kind: 'slice-joined', name: 'notes' }]); + }); +}); + +describe('reset', () => { + it('refolds a forked branch and keeps subscribers attached', async () => { + const tree = await openTree(); + const store = await openStore(tree); + await store.dispatch(counterAdded({ amount: 1 })); + await store.dispatch(counterAdded({ amount: 2 })); + await store.dispatch(counterAdded({ amount: 4 })); + await store.flush(); + + const forked = tree.createBranch('forked', { from: { branch: 'main', seq: 1 } }); + const causes: Cause[] = []; + store.subscribe((_state, cause) => causes.push(cause)); + await store.reset(journalFromBranch(forked, tree)); + + expect(store.ref.branch).toBe('forked'); + expect(store.slice('counter')).toBe(3); + expect(causes).toEqual([{ kind: 'reset', state: { counter: 3, notes: [] } }]); + + await store.dispatch(counterAdded({ amount: 8 })); + expect(store.slice('counter')).toBe(11); + + const reopened = await createEventStore({ journal: journalFromBranch(forked, tree), slices }); + expect(reopened.slice('counter')).toBe(11); + }); +}); + +describe('storeActor', () => { + function once(actor: ReturnType, type: string): Promise { + return new Promise((resolve) => { + const sub = actor.on(type, (event) => { + sub.unsubscribe(); + resolve(event as T); + }); + }); + } + + it('emits store.ready on start and store.changed after store.append', async () => { + const tree = await openTree(); + const store = await openStore(tree); + const actor = createActor(storeActor, { input: { store } }); + const ready = once<{ type: string }>(actor, 'store.ready'); + const changed = once<{ type: string }>(actor, 'store.changed'); + actor.start(); + actor.send({ type: 'store.append', event: counterAdded({ amount: 2 }) }); + expect((await ready).type).toBe('store.ready'); + expect((await changed).type).toBe('store.changed'); + expect(store.slice('counter')).toBe(2); + actor.stop(); + }); + + it('emits store.reset after store.switch to a forked journal', async () => { + const tree = await openTree(); + const store = await openStore(tree); + await store.dispatch(counterAdded({ amount: 1 })); + await store.dispatch(counterAdded({ amount: 2 })); + await store.flush(); + + const actor = createActor(storeActor, { input: { store } }); + const reset = once<{ type: string; branch: string }>(actor, 'store.reset'); + actor.start(); + const forked = tree.createBranch('forked', { from: { branch: 'main', seq: 0 } }); + actor.send({ type: 'store.switch', journal: journalFromBranch(forked, tree) }); + expect(await reset).toMatchObject({ type: 'store.reset', branch: 'forked' }); + expect(store.slice('counter')).toBe(1); + actor.stop(); + }); + + it('emits store.error when a dispatch fails', async () => { + const tree = await openTree(); + const store = await openStore(tree); + const actor = createActor(storeActor, { input: { store } }); + const failure = once<{ type: string }>(actor, 'store.error'); + actor.start(); + actor.send({ + type: 'store.append', + event: { type: 'test.unregistered_event', time: Date.now() }, + }); + expect((await failure).type).toBe('store.error'); + actor.stop(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/kimi-oauth/credential-source.test.ts b/packages/agent-core-v2/src/human/test/kimi-oauth/credential-source.test.ts deleted file mode 100644 index 91d03121ee8..00000000000 --- a/packages/agent-core-v2/src/human/test/kimi-oauth/credential-source.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { UNKNOWN_CAPABILITY } from '#/llm/capability'; -import type { StreamedMessagePart, VideoURLPart } from '#/llm/message'; -import type { MediaVideoUploader } from '#/llm/media/upload'; -import type { LlmErrorMessage } from '#/llm/errors'; -import type { LlmModel } from '#/llm/model'; -import type { LlmRequestControl, LlmRequester } from '#/llm/requester/requester'; -import { - kimiOAuthCredentialSource, - withAuth, - withAuthUpload, - type CredentialSource, -} from '#/kimi-oauth/index'; - -const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; - -type GenerateArgs = Parameters; - -function generateArgs(control: Partial = {}): GenerateArgs { - return [{ model }, { messages: [] }, { signal: new AbortController().signal, ...control }]; -} - -function statusError(status: number): LlmErrorMessage { - return { - kind: 'status', - statusCode: status, - message: `status ${status}`, - requestId: null, - retryAfterMs: null, - headers: null, - }; -} - -interface InnerCall { - readonly model: LlmModel; -} - -function createInner(plan: readonly (LlmErrorMessage | 'ok')[]) { - const calls: InnerCall[] = []; - const requester: LlmRequester = { - generate: (config, _content, { onEvent }) => { - calls.push({ model: config.model }); - const step = plan[Math.min(calls.length - 1, plan.length - 1)]; - if (step === 'ok') { - onEvent?.({ - type: 'llm.streaming.part', - part: { type: 'text', text: `call-${calls.length}` }, - }); - onEvent?.({ type: 'llm.done' }); - return Promise.resolve(); - } - onEvent?.({ type: 'llm.failed.remote', error: step }); - return Promise.resolve(); - }, - }; - return { requester, calls }; -} - -async function generateFailures( - requester: LlmRequester, - control: Partial = {}, -): Promise { - const failures: LlmErrorMessage[] = []; - await requester.generate( - ...generateArgs({ - ...control, - onEvent: (event) => { - if (event.type === 'llm.failed.remote' || event.type === 'llm.failed.syntax') { - failures.push(event.error); - } - }, - }), - ); - return failures; -} - -describe('withAuth', () => { - it('resolves credentials before each generate and forwards the resolved model', async () => { - const { requester, calls } = createInner(['ok']); - const source: CredentialSource = { - resolve: (m) => ({ ...m, apiKey: 'token-1' }), - }; - const wrapped = withAuth(requester, source); - - const parts: StreamedMessagePart[] = []; - await wrapped.generate( - ...generateArgs({ - onEvent: (event) => { - if (event.type === 'llm.streaming.part') { - parts.push(event.part); - } - }, - }), - ); - - expect(calls).toHaveLength(1); - expect(calls[0]?.model).toEqual({ ...model, apiKey: 'token-1' }); - expect(parts).toEqual([{ type: 'text', text: 'call-1' }]); - }); - - it('retries once with forced credentials when canRecover accepts the error', async () => { - const { requester, calls } = createInner([statusError(401), 'ok']); - const resolveOptions: unknown[] = []; - const source: CredentialSource = { - resolve: (m, options) => { - resolveOptions.push(options); - return { ...m, apiKey: options?.force === true ? 'token-2' : 'token-1' }; - }, - canRecover: (_m, error) => statusErrorStatus(error) === 401, - }; - const wrapped = withAuth(requester, source); - - const parts: StreamedMessagePart[] = []; - await wrapped.generate( - ...generateArgs({ - onEvent: (event) => { - if (event.type === 'llm.streaming.part') { - parts.push(event.part); - } - }, - }), - ); - - expect(calls).toHaveLength(2); - expect(calls[0]?.model.apiKey).toBe('token-1'); - expect(calls[1]?.model.apiKey).toBe('token-2'); - expect(resolveOptions).toEqual([undefined, { force: true }]); - expect(parts).toEqual([{ type: 'text', text: 'call-2' }]); - }); - - it('emits the failure when the retry also fails', async () => { - const { requester, calls } = createInner([statusError(401), statusError(401)]); - const source: CredentialSource = { - resolve: (m) => m, - canRecover: () => true, - }; - const wrapped = withAuth(requester, source); - - const failures = await generateFailures(wrapped); - - expect(calls).toHaveLength(2); - expect(failures).toHaveLength(1); - expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); - }); - - it('does not retry when canRecover rejects the error', async () => { - const { requester, calls } = createInner([statusError(401)]); - const source: CredentialSource = { - resolve: (m) => m, - canRecover: () => false, - }; - const wrapped = withAuth(requester, source); - - const failures = await generateFailures(wrapped); - - expect(calls).toHaveLength(1); - expect(failures).toHaveLength(1); - expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); - }); - - it('does not retry when the source has no canRecover', async () => { - const { requester, calls } = createInner([statusError(401)]); - const wrapped = withAuth(requester, { resolve: (m) => m }); - - const failures = await generateFailures(wrapped); - - expect(calls).toHaveLength(1); - expect(failures).toHaveLength(1); - expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); - }); - - it('does not retry when the signal is aborted', async () => { - const { requester, calls } = createInner([statusError(401)]); - const controller = new AbortController(); - controller.abort(); - const source: CredentialSource = { - resolve: (m) => m, - canRecover: () => true, - }; - const wrapped = withAuth(requester, source); - - const failures = await generateFailures(wrapped, { signal: controller.signal }); - - expect(calls).toHaveLength(1); - expect(failures).toHaveLength(1); - expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); - }); - - it('wraps uploadVideo with the same credential flow', async () => { - const part: VideoURLPart = { type: 'video_url', videoUrl: { url: 'ms://file-1', id: 'file-1' } }; - const seen: (string | undefined)[] = []; - let attempts = 0; - const inner: MediaVideoUploader = (_video, options) => { - attempts += 1; - seen.push(options.model.apiKey); - if (attempts === 1) { - return Promise.reject(statusError(401)); - } - return Promise.resolve(part); - }; - const source: CredentialSource = { - resolve: (m, options) => ({ ...m, apiKey: options?.force === true ? 'fresh' : 'stale' }), - canRecover: () => true, - }; - const wrapped = withAuthUpload(inner, source); - - const result = await wrapped({ data: new Uint8Array([1]), mimeType: 'video/mp4' }, { model }); - - expect(result).toBe(part); - expect(seen).toEqual(['stale', 'fresh']); - }); - - it('does not retry the upload when the signal is aborted', async () => { - const failure = statusError(401); - let attempts = 0; - const inner: MediaVideoUploader = () => { - attempts += 1; - return Promise.reject(failure); - }; - const controller = new AbortController(); - controller.abort(); - const wrapped = withAuthUpload(inner, { - resolve: (m) => m, - canRecover: () => true, - }); - - await expect( - wrapped( - { data: new Uint8Array([1]), mimeType: 'video/mp4' }, - { model, signal: controller.signal }, - ), - ).rejects.toBe(failure); - expect(attempts).toBe(1); - }); -}); - -describe('kimiOAuthCredentialSource', () => { - function createTokens() { - const calls: (boolean | undefined)[] = []; - return { - calls, - tokens: { - getAccessToken: (options?: { readonly force?: boolean }) => { - calls.push(options?.force); - return Promise.resolve('access-token'); - }, - }, - }; - } - - it('resolves the model apiKey from the token provider', async () => { - const { calls, tokens } = createTokens(); - const source = kimiOAuthCredentialSource(tokens); - - const resolved = await source.resolve({ ...model, baseUrl: 'https://example.com/v1' }); - - expect(resolved).toEqual({ - ...model, - baseUrl: 'https://example.com/v1', - apiKey: 'access-token', - }); - expect(calls).toEqual([false]); - }); - - it('passes force through to the token provider', async () => { - const { calls, tokens } = createTokens(); - const source = kimiOAuthCredentialSource(tokens); - - await source.resolve(model, { force: true }); - - expect(calls).toEqual([true]); - }); - - it('recovers only from 401 errors', () => { - const { tokens } = createTokens(); - const source = kimiOAuthCredentialSource(tokens); - - expect(source.canRecover?.(model, statusError(401))).toBe(true); - expect(source.canRecover?.(model, Object.assign(new Error('x'), { statusCode: 401 }))).toBe( - true, - ); - expect(source.canRecover?.(model, statusError(403))).toBe(false); - expect(source.canRecover?.(model, new Error('boom'))).toBe(false); - expect(source.canRecover?.(model, 'nope')).toBe(false); - }); -}); - -function statusErrorStatus(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) { - return undefined; - } - const record = error as Record; - const status = record['status'] ?? record['statusCode']; - return typeof status === 'number' ? status : undefined; -} diff --git a/packages/agent-core-v2/src/human/test/llm/anthropic-lower.test.ts b/packages/agent-core-v2/src/human/test/llm/anthropic-lower.test.ts index cf0d018db3b..76f822acc1e 100644 --- a/packages/agent-core-v2/src/human/test/llm/anthropic-lower.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/anthropic-lower.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { providerImagePolicy } from '#/llm/media/image-formats'; import type { Message } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; import { lowerMessage } from '#/llm/requester/bases/anthropic/lower'; @@ -65,20 +66,21 @@ function stubAnthropicClient(): { } describe('anthropic lowering of inline images', () => { - it('forwards a base64 image the Kimi trait accepts even though the route id is anthropic', () => { - const wire = lowerMessage(message, { trait: kimiAnthropicTrait, ctx: { model: routedModel } }); + it('forwards a base64 image the Kimi policy accepts even though the route id is anthropic', () => { + const wire = lowerMessage(message, providerImagePolicy('kimi').acceptedMimes); expect(wire[0]?.content[0]).toEqual(HEIC_BLOCK); }); it('refuses a base64 image outside the baseline set when no trait widens it', () => { - expect(() => - lowerMessage(message, { trait: undefined, ctx: { model: routedModel } }), - ).toThrow(/Unsupported media type for base64 image: image\/heic/); + expect(() => lowerMessage(message, providerImagePolicy().acceptedMimes)).toThrow( + /Unsupported media type for base64 image: image\/heic/, + ); }); it('sends the HEIC block on the wire when Kimi is reached over the Anthropic protocol', async () => { const client = stubAnthropicClient(); - const requester = createAnthropicRequester(kimiAnthropicTrait, { + const requester = createAnthropicRequester({ + trait: kimiAnthropicTrait, clientFactory: client.clientFactory, }); await requester.generate( diff --git a/packages/agent-core-v2/src/human/test/llm/cache-key.test.ts b/packages/agent-core-v2/src/human/test/llm/cache-key.test.ts index 777f70a79f9..4a4eae6963a 100644 --- a/packages/agent-core-v2/src/human/test/llm/cache-key.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/cache-key.test.ts @@ -105,7 +105,7 @@ function stubAnthropicClient(events: readonly Record[]): { describe('openai requester cacheKey', () => { it('encodes the cache key as prompt_cache_key by default', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, @@ -123,10 +123,10 @@ describe('openai requester cacheKey', () => { it('lets a trait override the cache key params', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester( - { cacheKey: (key) => ({ custom_cache: key }) }, - { clientFactory: client.clientFactory }, - ); + const requester = createOpenAIRequester({ + trait: { cacheKey: (key) => ({ custom_cache: key }) }, + clientFactory: client.clientFactory, + }); await requester.generate( { model, cacheKey: 'session-1' }, { messages }, @@ -138,7 +138,7 @@ describe('openai requester cacheKey', () => { it('omits prompt_cache_key when no cache key is given', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model }, { messages }, @@ -151,7 +151,7 @@ describe('openai requester cacheKey', () => { describe('anthropic requester cacheKey', () => { it('encodes the cache key as metadata.user_id', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createAnthropicRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, cacheKey: 'session-1', extraParams: { anthropic: { top_k: 5 } } }, { messages }, diff --git a/packages/agent-core-v2/src/human/test/llm/errors.test.ts b/packages/agent-core-v2/src/human/test/llm/errors.test.ts index ef3e8a3bb45..c259ef5b28f 100644 --- a/packages/agent-core-v2/src/human/test/llm/errors.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/errors.test.ts @@ -10,7 +10,7 @@ import { UNKNOWN_CAPABILITY } from '#/llm/capability'; import { createAssistantMessage, createUserMessage, type Message } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; import { classifyKimiQuotaError } from '#/llm-kimi/errors'; -import { kimiOpenAITrait } from '#/llm-kimi/trait'; +import { kimiConnection, kimiOpenAITrait } from '#/llm-kimi/trait'; import { createGoogleGenAIRequester } from '#/llm/requester/bases/google-genai/requester'; import { convertOpenAIError } from '#/llm/requester/bases/openai/format'; import { createOpenAIRequester } from '#/llm/requester/bases/openai/requester'; @@ -273,7 +273,7 @@ describe('requester error conversion', () => { } it('converts a 429 response to rate_limit', async () => { - const requester = createOpenAIRequester(undefined, { + const requester = createOpenAIRequester({ clientFactory: failingOpenAIClient( new RawOpenAISDKAPIError( 429, @@ -291,7 +291,10 @@ describe('requester error conversion', () => { }); it('converts a kimi quota response to quota_exhausted', async () => { - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + connection: kimiConnection, + trait: kimiOpenAITrait, + convertError: classifyKimiQuotaError, clientFactory: failingOpenAIClient( new RawOpenAISDKAPIError( 429, @@ -310,7 +313,7 @@ describe('requester error conversion', () => { it('emits llm.failed.syntax for a local message syntax error without sending a request', async () => { const clientFactory = vi.fn(() => ({}) as never); - const requester = createGoogleGenAIRequester(undefined, { clientFactory }); + const requester = createGoogleGenAIRequester({ clientFactory }); const events = await generateEvents(requester, [ createAssistantMessage([], [ { type: 'function', id: 'call-1', name: 'some_tool', arguments: 'not json' }, diff --git a/packages/agent-core-v2/src/human/test/llm/response-format.test.ts b/packages/agent-core-v2/src/human/test/llm/response-format.test.ts index fbce3e823e7..7fea582d590 100644 --- a/packages/agent-core-v2/src/human/test/llm/response-format.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/response-format.test.ts @@ -165,7 +165,7 @@ function stubGoogleClient(chunks: readonly Record[]): ClientStu describe('openai requester responseFormat', () => { it('maps json_object to response_format', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, responseFormat: jsonObjectFormat }, { messages }, @@ -176,7 +176,7 @@ describe('openai requester responseFormat', () => { it('maps json_schema to response_format.json_schema', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, responseFormat: jsonSchemaFormat }, { messages }, @@ -197,7 +197,7 @@ describe('openai requester responseFormat', () => { describe('openai-responses requester responseFormat', () => { it('maps json_schema to text.format', async () => { const client = stubResponsesClient(responsesStreamEvents); - const requester = createOpenAIResponsesRequester(undefined, { + const requester = createOpenAIResponsesRequester({ clientFactory: client.clientFactory, }); await requester.generate( @@ -229,7 +229,7 @@ describe('openai-responses requester responseFormat', () => { describe('anthropic requester responseFormat', () => { it('maps json_schema to output_config.format and keeps the thinking effort', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createAnthropicRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, thinking: { effort: 'high' }, responseFormat: jsonSchemaFormat }, { messages }, @@ -243,7 +243,7 @@ describe('anthropic requester responseFormat', () => { it('fails with a syntax error for json_object', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createAnthropicRequester({ clientFactory: client.clientFactory }); const events: LlmRequestEvent[] = []; await requester.generate( { model, responseFormat: jsonObjectFormat }, @@ -261,7 +261,7 @@ describe('anthropic requester responseFormat', () => { describe('google-genai requester responseFormat', () => { it('maps response formats to config', async () => { const client = stubGoogleClient(googleGenAIStreamChunks); - const requester = createGoogleGenAIRequester(undefined, { + const requester = createGoogleGenAIRequester({ clientFactory: client.clientFactory, }); await requester.generate( @@ -299,10 +299,10 @@ describe('requester toolMessageConversion', () => { const expectedText = 'shot taken\n(image omitted: tool result converted to plain text)'; const openAIClient = stubOpenAIClient(chatCompletionChunks); - await createOpenAIRequester( - { toolMessageConversion: () => 'extract_text' }, - { clientFactory: openAIClient.clientFactory }, - ).generate( + await createOpenAIRequester({ + trait: { toolMessageConversion: 'extract_text' }, + clientFactory: openAIClient.clientFactory, + }).generate( { model }, { messages: toolMessages }, { signal: new AbortController().signal }, @@ -315,10 +315,10 @@ describe('requester toolMessageConversion', () => { expect(JSON.stringify(chatMessages)).not.toContain('image_url'); const responsesClient = stubResponsesClient(responsesStreamEvents); - await createOpenAIResponsesRequester( - { toolMessageConversion: () => 'extract_text' }, - { clientFactory: responsesClient.clientFactory }, - ).generate( + await createOpenAIResponsesRequester({ + trait: { toolMessageConversion: 'extract_text' }, + clientFactory: responsesClient.clientFactory, + }).generate( { model }, { messages: toolMessages }, { signal: new AbortController().signal }, diff --git a/packages/agent-core-v2/src/human/test/llm/thinking.test.ts b/packages/agent-core-v2/src/human/test/llm/thinking.test.ts index 4544ad230e5..67a3f456787 100644 --- a/packages/agent-core-v2/src/human/test/llm/thinking.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/thinking.test.ts @@ -8,7 +8,7 @@ import { type Message, } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; -import type { TraitContext } from '#/llm/protocol/trait'; +import type { TraitContext } from '#/llm/protocol/base'; import { defaultThinkingEffortForModel, modelSupportsThinking, @@ -16,7 +16,8 @@ import { resolveThinkingKeep, type ModelThinkingMetadata, } from '#/llm/thinking'; -import { kimiOpenAITrait } from '#/llm-kimi/trait'; +import { kimiConnection, kimiOpenAITrait } from '#/llm-kimi/trait'; +import { classifyKimiQuotaError } from '#/llm-kimi/errors'; import { createOpenAIRequester } from '#/llm/requester/bases/openai/requester'; import type { LlmClientContext, LlmRequestEvent } from '#/llm/requester/requester'; @@ -29,6 +30,12 @@ const model: LlmModel = { const ctx: TraitContext = { model }; const messages: readonly Message[] = [createUserMessage('hi')]; +const kimiOpenAI = { + connection: kimiConnection, + trait: kimiOpenAITrait, + convertError: classifyKimiQuotaError, +} as const; + function modelWith(meta: ModelThinkingMetadata): LlmModel { return { ...model, ...meta }; } @@ -98,14 +105,15 @@ function bodyMessages(body: Record): Record[] describe('kimiOpenAITrait thinking', () => { it('encodes thinking configs and resolves thinking defaults and keep', () => { expect(kimiOpenAITrait.strictThinkingValidation).toBe(true); - expect(kimiOpenAITrait.withThinking?.({ effort: 'off' }, ctx)).toEqual({ - extra_body: { thinking: { type: 'disabled' } }, + expect(kimiOpenAITrait.thinking?.({ effort: 'off' }, ctx)).toEqual({ + kwargs: { thinking: { type: 'disabled' } }, }); - expect(kimiOpenAITrait.withThinking?.({ effort: 'on' }, ctx)).toEqual({ - extra_body: { thinking: { type: 'enabled' } }, + expect(kimiOpenAITrait.thinking?.({ effort: 'on' }, ctx)).toEqual({ + kwargs: { thinking: { type: 'enabled' } }, }); - expect(kimiOpenAITrait.withThinking?.({ effort: 'high', keep: 'all' }, ctx)).toEqual({ - extra_body: { thinking: { type: 'enabled', effort: 'high', keep: 'all' } }, + expect(kimiOpenAITrait.thinking?.({ effort: 'high', keep: 'all' }, ctx)).toEqual({ + kwargs: { thinking: { type: 'enabled', effort: 'high', keep: 'all' } }, + preserveThinking: true, }); const thinkingCapability: ModelCapability = { @@ -162,17 +170,26 @@ describe('kimiOpenAITrait thinking', () => { }); it('preserves thinking only when keep is all and thinking is not disabled', () => { - expect(kimiOpenAITrait.preserveThinking?.({ effort: 'on', keep: 'all' }, ctx)).toBe(true); - expect(kimiOpenAITrait.preserveThinking?.({ effort: 'off', keep: 'all' }, ctx)).toBeUndefined(); - expect(kimiOpenAITrait.preserveThinking?.({ effort: 'on' }, ctx)).toBeUndefined(); - expect(kimiOpenAITrait.preserveThinking?.({ effort: 'on', keep: '1' }, ctx)).toBeUndefined(); + expect(kimiOpenAITrait.thinking?.({ effort: 'on', keep: 'all' }, ctx)?.preserveThinking).toBe( + true, + ); + expect( + kimiOpenAITrait.thinking?.({ effort: 'off', keep: 'all' }, ctx)?.preserveThinking, + ).toBeUndefined(); + expect( + kimiOpenAITrait.thinking?.({ effort: 'on' }, ctx)?.preserveThinking, + ).toBeUndefined(); + expect( + kimiOpenAITrait.thinking?.({ effort: 'on', keep: '1' }, ctx)?.preserveThinking, + ).toBeUndefined(); }); }); describe('openai requester thinking', () => { - it('sends kimi thinking params at the top level', async () => { + it('sends kimi thinking params at the top level and flattens extra_body', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); await requester.generate( @@ -191,7 +208,8 @@ describe('openai requester thinking', () => { it('sends disabled thinking for off', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); await requester.generate( @@ -204,7 +222,7 @@ describe('openai requester thinking', () => { it('falls back to reasoning_effort when no trait handles thinking', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, thinking: { effort: 'high' } }, { messages }, @@ -223,7 +241,7 @@ describe('openai requester thinking', () => { it('sends nothing for on without a trait', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, thinking: { effort: 'on' } }, { messages }, @@ -235,7 +253,7 @@ describe('openai requester thinking', () => { it('sends the configured offEffort when thinking is off', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model: modelWith({ supportEfforts: ['low', 'high'], offEffort: 'none' }), @@ -249,7 +267,7 @@ describe('openai requester thinking', () => { it('rejects unsatisfiable off requests with guidance', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); const failing = async (target: LlmModel): Promise => { const events: LlmRequestEvent[] = []; await requester.generate( @@ -278,10 +296,10 @@ describe('openai requester thinking', () => { it('rejects an effort outside the supported list under strict validation', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester( - { strictThinkingValidation: true }, - { clientFactory: client.clientFactory }, - ); + const requester = createOpenAIRequester({ + trait: { strictThinkingValidation: true }, + clientFactory: client.clientFactory, + }); const events: LlmRequestEvent[] = []; await requester.generate( { model: modelWith({ supportEfforts: ['low', 'high'] }), thinking: { effort: 'max' } }, @@ -305,7 +323,7 @@ describe('openai requester thinking', () => { tool_use: true, }; const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); const events: LlmRequestEvent[] = []; await requester.generate( { model: { ...model, capability }, thinking: { effort: 'high' } }, @@ -321,7 +339,7 @@ describe('openai requester thinking', () => { it('keeps reasoning alive with medium effort when history has think parts', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model }, { @@ -337,7 +355,7 @@ describe('openai requester thinking', () => { it('echoes think parts under reasoning_content by default and restores marked reasoning_details', async () => { const client = stubOpenAIClient(chatCompletionChunks()); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, thinking: { effort: 'off' } }, { @@ -353,7 +371,7 @@ describe('openai requester thinking', () => { expect(assistant['content']).toBe('hello'); const marked = stubOpenAIClient(chatCompletionChunks()); - const markedRequester = createOpenAIRequester(undefined, { + const markedRequester = createOpenAIRequester({ clientFactory: marked.clientFactory, }); await markedRequester.generate( @@ -382,7 +400,8 @@ describe('openai requester thinking', () => { it('echoes an empty reasoning_content on think-less assistant messages only when keeping all', async () => { const preserving = stubOpenAIClient(chatCompletionChunks()); - const preservingRequester = createOpenAIRequester(kimiOpenAITrait, { + const preservingRequester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: preserving.clientFactory, }); await preservingRequester.generate( @@ -393,7 +412,8 @@ describe('openai requester thinking', () => { expect(bodyMessages(preserving.body())[1]!['reasoning_content']).toBe(''); const plain = stubOpenAIClient(chatCompletionChunks()); - const plainRequester = createOpenAIRequester(kimiOpenAITrait, { + const plainRequester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: plain.clientFactory, }); await plainRequester.generate( @@ -406,10 +426,10 @@ describe('openai requester thinking', () => { it('selects the outbound reasoning key from the trait declaration or inbound detection', async () => { const declared = stubOpenAIClient(chatCompletionChunks()); - const declaredRequester = createOpenAIRequester( - { reasoningKey: () => 'reasoning' }, - { clientFactory: declared.clientFactory }, - ); + const declaredRequester = createOpenAIRequester({ + trait: { reasoningKey: 'reasoning' }, + clientFactory: declared.clientFactory, + }); await declaredRequester.generate( { model, thinking: { effort: 'off' } }, { @@ -434,7 +454,7 @@ describe('openai requester thinking', () => { : chatCompletionChunks(); return openAIClient(chunks, captured) as never; }; - const detectedRequester = createOpenAIRequester(undefined, { clientFactory }); + const detectedRequester = createOpenAIRequester({ clientFactory }); await detectedRequester.generate( { model }, { messages }, @@ -465,10 +485,10 @@ describe('openai requester thinking', () => { { content: 'ok' }, ]), ); - const explicitRequester = createOpenAIRequester( - { reasoningKey: () => 'reasoning' }, - { clientFactory: explicit.clientFactory }, - ); + const explicitRequester = createOpenAIRequester({ + trait: { reasoningKey: 'reasoning' }, + clientFactory: explicit.clientFactory, + }); const explicitParts: unknown[] = []; await explicitRequester.generate( { model }, @@ -486,7 +506,7 @@ describe('openai requester thinking', () => { it('parses reasoning from stream deltas', async () => { const collect = async (chunks: Record[]) => { const client = stubOpenAIClient(chunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); const accumulator = createMessageAccumulator(); await requester.generate( { model }, diff --git a/packages/agent-core-v2/src/human/test/llm/trait.test.ts b/packages/agent-core-v2/src/human/test/llm/trait.test.ts index 57e13b67059..e9c9ed859f7 100644 --- a/packages/agent-core-v2/src/human/test/llm/trait.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/trait.test.ts @@ -26,9 +26,11 @@ import { KIMI_BASE_URL_ENV, KIMI_DEFAULT_BASE_URL, kimiAnthropicTrait, + kimiConnection, kimiOpenAITrait, } from '#/llm-kimi/trait'; -import { anthropicProvider, googleGenAITrait, openaiProvider } from '#/llm/provider/providers/standard'; +import { classifyKimiQuotaError } from '#/llm-kimi/errors'; +import { anthropicProvider, googleGenAIConnection, openaiProvider } from '#/llm/provider/providers/standard'; import type { LlmClientContext, LlmRequester, LlmRequestEvent } from '#/llm/requester/requester'; import type { TokenUsage } from '#/llm/usage'; import { @@ -51,6 +53,18 @@ const model: LlmModel = { }; const messages: readonly Message[] = [createUserMessage('hi')]; +const kimiOpenAI = { + connection: kimiConnection, + trait: kimiOpenAITrait, + convertError: classifyKimiQuotaError, +} as const; + +const kimiAnthropic = { + connection: kimiConnection, + trait: kimiAnthropicTrait, + convertError: classifyKimiQuotaError, +} as const; + async function generateAndCollectUsage( requester: LlmRequester, ): Promise { @@ -213,12 +227,12 @@ function stubGoogleClient(chunks: readonly Record[]): ClientStu } describe('defaultHeaders', () => { - it('sends trait-declared headers on openai requests', async () => { + it('sends connection-declared headers on openai requests', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester( - { defaultHeaders: () => ({ 'x-trait': 'a' }) }, - { clientFactory: client.clientFactory }, - ); + const requester = createOpenAIRequester({ + connection: { defaultHeaders: () => ({ 'x-trait': 'a' }) }, + clientFactory: client.clientFactory, + }); await requester.generate( { model }, { messages }, @@ -229,7 +243,7 @@ describe('defaultHeaders', () => { it('sends model defaultHeaders on openai requests', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model: { ...model, defaultHeaders: { 'x-model': 'b' } } }, { messages }, @@ -238,12 +252,12 @@ describe('defaultHeaders', () => { expect(client.headers()?.['x-model']).toBe('b'); }); - it('lets model headers override trait headers', async () => { + it('lets model headers override connection headers', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester( - { defaultHeaders: () => ({ 'x-k': 'trait' }) }, - { clientFactory: client.clientFactory }, - ); + const requester = createOpenAIRequester({ + connection: { defaultHeaders: () => ({ 'x-k': 'trait' }) }, + clientFactory: client.clientFactory, + }); await requester.generate( { model: { ...model, defaultHeaders: { 'x-k': 'model' } } }, { messages }, @@ -254,10 +268,10 @@ describe('defaultHeaders', () => { it('sends merged headers on anthropic requests', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester( - { defaultHeaders: () => ({ 'x-trait': 'a' }) }, - { clientFactory: client.clientFactory }, - ); + const requester = createAnthropicRequester({ + connection: { defaultHeaders: () => ({ 'x-trait': 'a' }) }, + clientFactory: client.clientFactory, + }); let finish: FinishInfo | undefined; let messageId: string | undefined; await requester.generate( @@ -279,7 +293,7 @@ describe('defaultHeaders', () => { }); describe('capability', () => { - it('resolves capabilities from the base prefixes and the trait hook', () => { + it('resolves capabilities from the base prefixes and the variant hook', () => { const reasoning = openaiProvider.resolveModel('o1').capability; expect(reasoning.thinking).toBe(true); expect(reasoning.tool_use).toBe(true); @@ -305,11 +319,11 @@ describe('capability', () => { isUnknownCapability(anthropicProvider.resolveModel('no-such-model').capability), ).toBe(true); - const traitCapProvider = createProvider({ - id: 'test-trait-cap', - protocols: { openai: { base: openAIBase, trait: { capability: () => TRAIT_CAPABILITY } } }, + const variantCapProvider = createProvider({ + id: 'test-variant-cap', + protocols: { openai: { base: openAIBase, capability: () => TRAIT_CAPABILITY } }, }); - expect(traitCapProvider.resolveModel('o1').capability).toBe(TRAIT_CAPABILITY); + expect(variantCapProvider.resolveModel('o1').capability).toBe(TRAIT_CAPABILITY); }); it('enriches listModels seeds and returns an empty list without a model source', async () => { @@ -448,7 +462,7 @@ describe('endpoint', () => { vi.unstubAllEnvs(); }); - it('injects the endpoint from env and trait defaults at request time', async () => { + it('injects the endpoint from env and connection defaults at request time', async () => { vi.stubEnv(KIMI_BASE_URL_ENV, ''); vi.stubEnv(KIMI_API_KEY_ENV, 'env-key'); const seen: LlmModel[] = []; @@ -465,7 +479,8 @@ describe('endpoint', () => { }, }; }); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); const signal = new AbortController().signal; @@ -549,7 +564,7 @@ describe('protocol variant flags', () => { it('uses the anthropic beta api when the model opts in', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { + const requester = createAnthropicRequester({ clientFactory: client.clientFactory, }); await requester.generate( @@ -587,7 +602,8 @@ describe('protocol variant flags', () => { }, }; }); - const requester = createGoogleGenAIRequester(googleGenAITrait, { + const requester = createGoogleGenAIRequester({ + connection: googleGenAIConnection, clientFactory: client.clientFactory, }); const signal = new AbortController().signal; @@ -617,7 +633,8 @@ describe('convertTool', () => { it('maps $-prefixed tools to builtin_function', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); await requester.generate( @@ -634,7 +651,8 @@ describe('convertTool', () => { it('normalizes tool schemas for kimi', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); await requester.generate( @@ -653,7 +671,7 @@ describe('convertTool', () => { it('uses the default tool mapping without a trait', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, tools: [tools[1]!] }, { messages }, @@ -676,7 +694,8 @@ describe('message-level tools', () => { it('serializes system message tools for kimi', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); await requester.generate( @@ -695,7 +714,7 @@ describe('message-level tools', () => { it('drops system message tools without a trait', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model }, { messages: [{ role: 'system', content: [], tools: [...declared] }, ...messages] }, @@ -709,7 +728,8 @@ describe('message-level tools', () => { describe('withMaxCompletionTokens', () => { it('encodes max completion tokens via the kimi trait', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); await requester.generate( @@ -723,7 +743,7 @@ describe('withMaxCompletionTokens', () => { it('uses max_completion_tokens for reasoning models without a trait', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model: { ...model, model: 'gpt-5.1' }, maxCompletionTokens: 1000 }, { messages }, @@ -735,7 +755,7 @@ describe('withMaxCompletionTokens', () => { it('uses max_tokens for other models without a trait', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model: { ...model, model: 'gpt-4o' }, maxCompletionTokens: 1000 }, { messages }, @@ -747,7 +767,7 @@ describe('withMaxCompletionTokens', () => { it('caps by the remaining context budget', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model: { ...model, model: 'gpt-4o' }, maxCompletionTokens: 1000, maxContextTokens: 500 }, { messages, usedContextTokens: 200 }, @@ -758,7 +778,7 @@ describe('withMaxCompletionTokens', () => { it('passes max_tokens on the anthropic request path', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createAnthropicRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, maxCompletionTokens: 1000, maxContextTokens: 500 }, { messages, usedContextTokens: 200 }, @@ -797,12 +817,10 @@ describe('withMaxCompletionTokens', () => { describe('buildParams', () => { it('lets the trait reshape the final params', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester( - { - buildParams: (params) => ({ ...params, x_custom: 1 }), - }, - { clientFactory: client.clientFactory }, - ); + const requester = createOpenAIRequester({ + trait: { buildParams: (params) => ({ ...params, x_custom: 1 }) }, + clientFactory: client.clientFactory, + }); await requester.generate( { model }, { messages }, @@ -830,7 +848,8 @@ describe('extractUsage', () => { ], }, ]); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); const usage = await generateAndCollectUsage(requester); @@ -843,7 +862,8 @@ describe('extractUsage', () => { { id: 'c1', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }] }, { id: 'c1', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [{ index: 0, delta: {}, finish_reason: 'stop', usage: { prompt_tokens: 4, completion_tokens: 6 } }] }, ]); - const requester = createOpenAIRequester(kimiOpenAITrait, { + const requester = createOpenAIRequester({ + ...kimiOpenAI, clientFactory: client.clientFactory, }); const usage = await generateAndCollectUsage(requester); @@ -862,7 +882,7 @@ describe('extractUsage', () => { usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }, ]); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); let usage: TokenUsage | undefined; let finish: FinishInfo | undefined; let messageId: string | undefined; @@ -967,7 +987,7 @@ describe('toolCallIdPolicy', () => { it('sanitizes tool call ids on the openai request path', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); await requester.generate( { model }, { @@ -990,15 +1010,15 @@ describe('toolCallIdPolicy', () => { it('lets the trait override the policy', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester( - { - toolCallIdPolicy: () => ({ + const requester = createOpenAIRequester({ + trait: { + toolCallIdPolicy: { normalize: (id) => sanitizeToolCallId(id, 4), maxLength: 4, - }), + }, }, - { clientFactory: client.clientFactory }, - ); + clientFactory: client.clientFactory, + }); await requester.generate( { model }, { @@ -1019,7 +1039,7 @@ describe('toolCallIdPolicy', () => { it('sanitizes tool call ids on the anthropic request path', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createAnthropicRequester({ clientFactory: client.clientFactory }); await requester.generate( { model }, { @@ -1049,12 +1069,10 @@ describe('toolCallIdPolicy', () => { describe('mergeHistory', () => { it('lets the trait merge the converted history', async () => { const client = stubOpenAIClient(chatCompletionChunks); - const requester = createOpenAIRequester( - { - mergeHistory: (history) => [...history, { role: 'user', content: 'extra' }], - }, - { clientFactory: client.clientFactory }, - ); + const requester = createOpenAIRequester({ + trait: { mergeHistory: (history) => [...history, { role: 'user', content: 'extra' }] }, + clientFactory: client.clientFactory, + }); await requester.generate( { model }, { messages }, @@ -1065,11 +1083,164 @@ describe('mergeHistory', () => { }); }); -describe('anthropic trait dialect', () => { +describe('request pipeline', () => { + it('composes format stages and trait hooks in a fixed order', async () => { + const order: string[] = []; + let historySeenByMerge: readonly unknown[] | undefined; + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester({ + trait: { + cacheKey: (key) => { + order.push('cacheKey'); + return { prompt_cache_key: key }; + }, + thinking: () => { + order.push('thinking'); + return { kwargs: { reasoning_effort: 'high' } }; + }, + convertMessage: (message, converted) => { + order.push(`convertMessage:${message.role}`); + return converted; + }, + mergeHistory: (history) => { + order.push('mergeHistory'); + historySeenByMerge = history; + return history; + }, + convertTool: (tool) => { + order.push('convertTool'); + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + }; + }, + buildParams: (params) => { + order.push('buildParams'); + return params; + }, + }, + clientFactory: client.clientFactory, + }); + await requester.generate( + { + model, + systemPrompt: 'sys', + cacheKey: 'cache-1', + thinking: { effort: 'high' }, + tools: [{ name: 'get_weather', description: 'get weather', parameters: { type: 'object' } }], + }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(order).toEqual([ + 'cacheKey', + 'thinking', + 'convertMessage:user', + 'mergeHistory', + 'convertTool', + 'buildParams', + ]); + const body = client.body(); + expect(body['prompt_cache_key']).toBe('cache-1'); + expect(body['reasoning_effort']).toBe('high'); + expect(historySeenByMerge?.[0]).toEqual({ role: 'system', content: 'sys' }); + const bodyMessages = body['messages'] as Record[]; + expect(bodyMessages[0]).toEqual({ role: 'system', content: 'sys' }); + }); +}); + +describe('toolMessageConversion request config', () => { + const toolHistory: readonly Message[] = [ + createUserMessage('hi'), + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: 'call_1', name: 'get_weather', arguments: '{}' }], + ), + { + role: 'tool', + toolCallId: 'call_1', + content: [ + { type: 'text', text: 'sunny' }, + { type: 'image_url', imageUrl: { url: 'https://example.test/x.png' } }, + ], + }, + ]; + + it('applies request-level extract_text on the openai path', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); + await requester.generate( + { model, toolMessageConversion: 'extract_text' }, + { messages: toolHistory }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + const tool = bodyMessages.find((message) => message['role'] === 'tool'); + expect(tool?.['content']).toBe('sunny\n(image omitted: tool result converted to plain text)'); + expect( + bodyMessages.some((message) => message['role'] === 'user' && Array.isArray(message['content'])), + ).toBe(false); + }); + + it('applies request-level keep_parts on the openai path', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester({ clientFactory: client.clientFactory }); + await requester.generate( + { model, toolMessageConversion: 'keep_parts' }, + { messages: toolHistory }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + const tool = bodyMessages.find((message) => message['role'] === 'tool'); + expect(tool?.['content']).toEqual([ + { type: 'text', text: 'sunny' }, + { type: 'image_url', image_url: { url: 'https://example.test/x.png' } }, + ]); + }); + + it('lets the request config override the trait default', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester({ + ...kimiOpenAI, + clientFactory: client.clientFactory, + }); + await requester.generate( + { model, toolMessageConversion: 'extract_text' }, + { messages: toolHistory }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + const tool = bodyMessages.find((message) => message['role'] === 'tool'); + expect(tool?.['content']).toBe('sunny\n(image omitted: tool result converted to plain text)'); + }); + + it('applies request-level extract_text on the responses path', async () => { + const client = stubResponsesClient([ + { type: 'response.completed', response: { id: 'resp_1', status: 'completed' } }, + ]); + const requester = createOpenAIResponsesRequester({ + clientFactory: client.clientFactory, + }); + await requester.generate( + { model, toolMessageConversion: 'extract_text' }, + { messages: toolHistory }, + { signal: new AbortController().signal }, + ); + const input = client.body()['input'] as Record[]; + const output = input.find((item) => item['type'] === 'function_call_output'); + expect(output?.['output']).toBe('sunny\n(image omitted: tool result converted to plain text)'); + }); +}); + +describe('anthropic trait', () => { it('lets the trait reshape messages, history, and tools', async () => { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester( - { + const requester = createAnthropicRequester({ + trait: { convertMessage: (message, converted) => { if (extractText(message) === 'drop me') { return null; @@ -1092,8 +1263,8 @@ describe('anthropic trait dialect', () => { input_schema: tool.parameters, }), }, - { clientFactory: client.clientFactory }, - ); + clientFactory: client.clientFactory, + }); await requester.generate( { model, @@ -1126,7 +1297,7 @@ describe('anthropic user message merging', () => { async function generate(history: readonly Message[]): Promise[]> { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createAnthropicRequester({ clientFactory: client.clientFactory }); await requester.generate( { model }, { messages: history }, @@ -1181,7 +1352,7 @@ describe('anthropic cache control', () => { systemPrompt?: string, ): Promise> { const client = stubAnthropicClient(anthropicStreamEvents); - const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const requester = createAnthropicRequester({ clientFactory: client.clientFactory }); await requester.generate( { model, systemPrompt, tools }, { messages: history }, @@ -1259,7 +1430,8 @@ describe('anthropic thinking kwargs', () => { { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } }, { type: 'message_stop' }, ]); - const requester = createAnthropicRequester(kimiAnthropicTrait, { + const requester = createAnthropicRequester({ + ...kimiAnthropic, betaApi: true, clientFactory: client.clientFactory, }); @@ -1310,12 +1482,15 @@ describe('anthropic thinking kwargs', () => { expect(client.betaCalled()).toBe(true); const betaFeatureTrait = { - withThinking: () => ({ - thinking: { type: 'enabled' }, - betaFeatures: ['interleaved-thinking-2025-05-14', 'custom-beta'], + thinking: () => ({ + kwargs: { + thinking: { type: 'enabled' }, + betaFeatures: ['interleaved-thinking-2025-05-14', 'custom-beta'], + }, }), }; - const betaRequester = createAnthropicRequester(betaFeatureTrait, { + const betaRequester = createAnthropicRequester({ + trait: betaFeatureTrait, betaApi: true, clientFactory: client.clientFactory, }); @@ -1344,7 +1519,8 @@ describe('anthropic thinking kwargs', () => { 'context-management-2025-06-27', ]); - const plainBetaRequester = createAnthropicRequester(betaFeatureTrait, { + const plainBetaRequester = createAnthropicRequester({ + trait: betaFeatureTrait, clientFactory: client.clientFactory, }); await plainBetaRequester.generate( @@ -1360,7 +1536,7 @@ describe('anthropic thinking kwargs', () => { ); expect(client.betaCalled()).toBe(false); - const defaultRequester = createAnthropicRequester(undefined, { + const defaultRequester = createAnthropicRequester({ clientFactory: client.clientFactory, }); await defaultRequester.generate( @@ -1551,7 +1727,7 @@ describe('openai responses base', () => { }, }, ]); - const requester = createOpenAIResponsesRequester(undefined, { + const requester = createOpenAIResponsesRequester({ clientFactory: client.clientFactory, }); const parts: StreamedMessagePart[] = []; @@ -1684,7 +1860,7 @@ describe('google genai base', () => { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, cachedContentTokenCount: 4 }, }, ]); - const requester = createGoogleGenAIRequester(undefined, { + const requester = createGoogleGenAIRequester({ clientFactory: client.clientFactory, }); const parts: StreamedMessagePart[] = []; diff --git a/packages/agent-core-v2/src/human/test/llm/usage.test.ts b/packages/agent-core-v2/src/human/test/llm/usage.test.ts index f20e09caa32..56b2f8ee591 100644 --- a/packages/agent-core-v2/src/human/test/llm/usage.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/usage.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'; import type { StreamParseSink } from '#/llm/protocol/format'; import { anthropicFormat } from '#/llm/requester/bases/anthropic/format'; -import { openAIFormat } from '#/llm/requester/bases/openai/format'; +import { createOpenAIFormat } from '#/llm/requester/bases/openai/format'; import type { TokenUsage } from '#/llm/usage'; +const openAIFormat = createOpenAIFormat(); + function createSink() { const usages: Partial[] = []; const sink: StreamParseSink = { diff --git a/packages/agent-core-v2/src/human/test/media/tool.test.ts b/packages/agent-core-v2/src/human/test/media/tool.test.ts index b1a5dae458b..55c7f346876 100644 --- a/packages/agent-core-v2/src/human/test/media/tool.test.ts +++ b/packages/agent-core-v2/src/human/test/media/tool.test.ts @@ -7,7 +7,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createActor, waitFor } from '#/xstate2'; import { createAgentMachine } from '#/agent/machine'; +import { agentSlices, type AgentEventStore } from '#/agent/slices'; import { createTurnMachine } from '#/agent/turn'; +import { createEventStore } from '#/eventStore/eventStore'; +import { journalFromBranch } from '#/eventStore/journal'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; import type { ModelCapability } from '#/llm/capability'; import { createAssistantMessage, @@ -22,10 +27,8 @@ import { createMediaRefResolver } from '#/llm/media/resolver'; import { createMemoryMediaStore } from '#/llm/media/store'; import type { LlmModel } from '#/llm/model'; import { createProvider } from '#/llm/provider/definition'; -import { createLlmMachine } from '#/llm/requester/machine'; import type { LlmRequester } from '#/llm/requester/requester'; -import { openAIFormat } from '#/llm/requester/bases/openai/format'; -import { openAIBase } from '#/llm/requester/bases/openai/requester'; +import { openAIBase, planOpenAIRequest } from '#/llm/requester/bases/openai/requester'; import { createReadMediaFileTool } from '#/media/tool'; const CAPABILITY: ModelCapability = { @@ -48,6 +51,14 @@ function toolCall(id: string, name: string, args: string): ToolCall { return { type: 'function', id, name, arguments: args }; } +async function testStore(): Promise { + const backend = new MemoryBackend(); + const store = await TreeStore.open(backend, {}); + const tree = await store.tree('test'); + tree.createBranch('main'); + return createEventStore({ journal: journalFromBranch(tree.openBranch('main'), tree), slices: agentSlices }); +} + afterEach(() => { for (const dir of tmpDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -163,27 +174,25 @@ describe('media stack wiring', () => { }, }; + const agentStore = await testStore(); const actor = createActor( createAgentMachine({ tools, - turnActor: createTurnMachine( - createLlmMachine({ - requester, - messageResolvers: [ - createMediaRefResolver({ - providers: [provider], - source: store, - cache: createMemoryMediaUploadCache(), - }), - ], - }), - ), + turnActor: createTurnMachine(requester, { + messageResolvers: [ + createMediaRefResolver({ + providers: [provider], + source: store, + cache: createMemoryMediaUploadCache(), + }), + ], + }), }), - { input: { request: { model } } }, + { input: { request: { model }, store: agentStore } }, ); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('watch this') }); - await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length > 1, { + await waitFor(actor, (s) => s.matches('idle') && agentStore.getState().history.length > 1, { timeout: 5000, }); @@ -196,11 +205,10 @@ describe('media stack wiring', () => { ]); expect(uploadVideo).toHaveBeenCalledTimes(1); - const wire = openAIFormat.formatRequest({ + const wire = planOpenAIRequest({ model, messages: seenMessages[1] as readonly Message[], tools: [], - ctx: { model }, }); const wireMessages = wire.params.messages as unknown as Record[]; const toolWire = wireMessages.find((message) => message['role'] === 'tool'); diff --git a/packages/agent-core-v2/src/human/test/session/machine.test.ts b/packages/agent-core-v2/src/human/test/session/machine.test.ts index 9008fd436a9..9154ea6a359 100644 --- a/packages/agent-core-v2/src/human/test/session/machine.test.ts +++ b/packages/agent-core-v2/src/human/test/session/machine.test.ts @@ -8,10 +8,11 @@ import { extractText, } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; -import { createLlmMachine } from '#/llm/requester/machine'; import type { LlmRequester } from '#/llm/requester/requester'; import { emptyUsage } from '#/llm/usage'; import { createAgentMachine } from '#/agent/machine'; +import { messageAppended, turnEnded } from '#/agent/events'; +import { agentSlices, type AgentEventStore } from '#/agent/slices'; import { createAssistantEntry, createTurnMachine, @@ -23,6 +24,12 @@ import { createSessionMachine, type AgentActorRef, } from '#/session/machine'; +import { createEventStore } from '#/eventStore/eventStore'; +import { journalFromBranch } from '#/eventStore/journal'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; +import type { BranchRef } from '#/store/types'; +import type { Tree } from '#/store/tree'; const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; @@ -45,7 +52,7 @@ function createTestSession(requester: LlmRequester): SessionActor { createSessionMachine({ agent: createAgentMachine({ tools: [], - turnActor: createTurnMachine(createLlmMachine({ requester })), + turnActor: createTurnMachine(requester), }), }), { input: { request: { model } } }, @@ -54,6 +61,32 @@ function createTestSession(requester: LlmRequester): SessionActor { return session; } +interface TestEnv { + tree: Tree; + open(branch: string, from?: BranchRef): Promise; +} + +async function testEnv(): Promise { + const backend = new MemoryBackend(); + const store = await TreeStore.open(backend, {}); + const tree = await store.tree('test'); + return { + tree, + open: (branch, from) => { + if (!tree.has(branch)) { + tree.createBranch(branch, from !== undefined ? { from } : undefined); + } + return createEventStore({ journal: journalFromBranch(tree.openBranch(branch), tree), slices: agentSlices }); + }, + }; +} + +function forkStore(env: TestEnv, source: AgentEventStore, branch: string): Promise { + const sourceBranch = env.tree.openBranch(source.ref.branch); + const head = sourceBranch.head; + return env.open(branch, head === null ? undefined : { branch: sourceBranch.name, seq: head }); +} + function agentRef(session: SessionActor, agentId: string): AgentActorRef { const entry = session.getSnapshot().context.agents[agentId]; expect(entry).toBeDefined(); @@ -68,10 +101,10 @@ function submit(session: SessionActor, agentId: string, text: string): void { }); } -async function waitIdle(ref: AgentActorRef, messageCount: number) { +async function waitIdle(ref: AgentActorRef, store: AgentEventStore, messageCount: number) { return waitFor( ref, - (snapshot) => snapshot.matches('idle') && snapshot.context.messages.length === messageCount, + (snapshot) => snapshot.matches('idle') && store.getState().history.length === messageCount, { timeout: 5000 }, ); } @@ -85,9 +118,10 @@ describe('session machine agent lifecycle', () => { const session = createTestSession(createEchoRequester()); const created: string[] = []; session.on('agent.created', (event) => created.push(event.agentId)); + const env = await testEnv(); - session.send({ type: 'agent.create' }); - session.send({ type: 'agent.create' }); + session.send({ type: 'agent.create', input: { store: await env.open('agent-1') } }); + session.send({ type: 'agent.create', input: { store: await env.open('agent-2') } }); expect(created).toEqual(['agent-1', 'agent-2']); expect(Object.keys(session.getSnapshot().context.agents).toSorted()).toEqual(['agent-1', 'agent-2']); @@ -95,22 +129,29 @@ describe('session machine agent lifecycle', () => { it('creates a agent with restored messages and turnId', async () => { const session = createTestSession(createEchoRequester()); - const history: HistoryMessage[] = [ - createUserEntry(createUserMessage('old'), { source: 'input' }), - createAssistantEntry(createAssistantMessage([{ type: 'text', text: 'echo:old' }]), { - source: 'llm', - usage: emptyUsage(), + const env = await testEnv(); + const store = await env.open('restored'); + await store.dispatch( + messageAppended({ message: createUserEntry(createUserMessage('old'), { source: 'input' }) }), + ); + await store.dispatch( + messageAppended({ + message: createAssistantEntry(createAssistantMessage([{ type: 'text', text: 'echo:old' }]), { + source: 'llm', + usage: emptyUsage(), + }), }), - ]; - session.send({ type: 'agent.create', agentId: 'restored', input: { history, turnId: 7 } }); + ); + await store.dispatch(turnEnded({ turnId: 6, outcome: 'done' })); + session.send({ type: 'agent.create', agentId: 'restored', input: { store } }); const ref = agentRef(session, 'restored'); - expect(ref.getSnapshot().context.turnId).toBe(7); + expect(store.getState().turnIndex.nextTurnId).toBe(7); submit(session, 'restored', 'new'); - const snapshot = await waitIdle(ref, 4); + await waitIdle(ref, store, 4); - expect(snapshot.context.turnId).toBe(8); - expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + expect(store.getState().turnIndex.nextTurnId).toBe(8); + expect(rolesAndTexts(store.getState().history)).toEqual([ 'user:old', 'assistant:echo:old', 'user:new', @@ -122,10 +163,11 @@ describe('session machine agent lifecycle', () => { const session = createTestSession(createEchoRequester()); const errors: string[] = []; session.on('agent.failed', (event) => errors.push(event.error)); + const env = await testEnv(); - session.send({ type: 'agent.create', agentId: 'a' }); + session.send({ type: 'agent.create', agentId: 'a', input: { store: await env.open('a') } }); const first = agentRef(session, 'a'); - session.send({ type: 'agent.create', agentId: 'a' }); + session.send({ type: 'agent.create', agentId: 'a', input: { store: await env.open('a-dup') } }); expect(errors).toEqual([`duplicate agent id: 'a'`]); expect(agentRef(session, 'a')).toBe(first); @@ -133,7 +175,8 @@ describe('session machine agent lifecycle', () => { it('stops a agent and removes it from the registry', async () => { const session = createTestSession(createEchoRequester()); - session.send({ type: 'agent.create', agentId: 'a' }); + const env = await testEnv(); + session.send({ type: 'agent.create', agentId: 'a', input: { store: await env.open('a') } }); const ref = agentRef(session, 'a'); const stopped: string[] = []; session.on('agent.stopped', (event) => stopped.push(event.agentId)); @@ -176,8 +219,11 @@ describe('session machine concurrent agents', () => { }, }; const session = createTestSession(requester); - session.send({ type: 'agent.create', agentId: 'a' }); - session.send({ type: 'agent.create', agentId: 'b' }); + const env = await testEnv(); + const storeA = await env.open('a'); + const storeB = await env.open('b'); + session.send({ type: 'agent.create', agentId: 'a', input: { store: storeA } }); + session.send({ type: 'agent.create', agentId: 'b', input: { store: storeB } }); submit(session, 'a', 'hello-a'); submit(session, 'b', 'hello-b'); @@ -190,16 +236,16 @@ describe('session machine concurrent agents', () => { resolvers.get('hello-a')?.(); resolvers.get('hello-b')?.(); - const [snapshotA, snapshotB] = await Promise.all([ - waitIdle(agentRef(session, 'a'), 2), - waitIdle(agentRef(session, 'b'), 2), + await Promise.all([ + waitIdle(agentRef(session, 'a'), storeA, 2), + waitIdle(agentRef(session, 'b'), storeB, 2), ]); - expect(rolesAndTexts(snapshotA.context.messages)).toEqual([ + expect(rolesAndTexts(storeA.getState().history)).toEqual([ 'user:hello-a', 'assistant:echo:hello-a', ]); - expect(rolesAndTexts(snapshotB.context.messages)).toEqual([ + expect(rolesAndTexts(storeB.getState().history)).toEqual([ 'user:hello-b', 'assistant:echo:hello-b', ]); @@ -209,112 +255,51 @@ describe('session machine concurrent agents', () => { describe('session machine agent fork', () => { it('forks a agent with the source context and diverges afterwards', async () => { const session = createTestSession(createEchoRequester()); - session.send({ type: 'agent.create', agentId: 'a' }); + const env = await testEnv(); + const storeA = await env.open('a'); + session.send({ type: 'agent.create', agentId: 'a', input: { store: storeA } }); submit(session, 'a', 'hi'); - const snapshotA = await waitIdle(agentRef(session, 'a'), 2); - expect(snapshotA.context.turnId).toBe(1); + await waitIdle(agentRef(session, 'a'), storeA, 2); + expect(storeA.getState().turnIndex.nextTurnId).toBe(1); + await storeA.flush(); const forked: string[] = []; session.on('agent.forked', (event) => forked.push(event.agentId)); - session.send({ type: 'agent.fork', sourceId: 'a', agentId: 'b' }); + const storeB = await forkStore(env, storeA, 'b'); + session.send({ type: 'agent.fork', sourceId: 'a', agentId: 'b', store: storeB }); expect(forked).toEqual(['b']); const refB = agentRef(session, 'b'); - const forkSnapshot = refB.getSnapshot(); - expect(forkSnapshot.value).toEqual({ idle: 'ready' }); - expect(forkSnapshot.context.turnId).toBe(1); - expect(rolesAndTexts(forkSnapshot.context.messages)).toEqual([ + expect(refB.getSnapshot().value).toEqual({ idle: 'ready' }); + expect(storeB.getState().turnIndex.nextTurnId).toBe(1); + expect(rolesAndTexts(storeB.getState().history)).toEqual([ 'user:hi', 'assistant:echo:hi', ]); submit(session, 'b', 'fork-hi'); - const snapshotB = await waitIdle(refB, 4); + await waitIdle(refB, storeB, 4); - expect(snapshotB.context.turnId).toBe(2); - expect(rolesAndTexts(snapshotB.context.messages)).toEqual([ + expect(storeB.getState().turnIndex.nextTurnId).toBe(2); + expect(rolesAndTexts(storeB.getState().history)).toEqual([ 'user:hi', 'assistant:echo:hi', 'user:fork-hi', 'assistant:echo:fork-hi', ]); - expect(agentRef(session, 'a').getSnapshot().context.messages).toHaveLength(2); - expect(agentRef(session, 'a').getSnapshot().context.turnId).toBe(1); + expect(storeA.getState().history).toHaveLength(2); + expect(storeA.getState().turnIndex.nextTurnId).toBe(1); }); it('emits agent.failed when forking an unknown source', async () => { const session = createTestSession(createEchoRequester()); const errors: string[] = []; session.on('agent.failed', (event) => errors.push(event.error)); + const env = await testEnv(); - session.send({ type: 'agent.fork', sourceId: 'nope', agentId: 'b' }); + session.send({ type: 'agent.fork', sourceId: 'nope', agentId: 'b', store: await env.open('b') }); expect(errors).toEqual([`unknown agent: 'nope'`]); expect(session.getSnapshot().context.agents['b']).toBeUndefined(); }); }); - - -describe('session machine agent switch', () => { - it('switches an idle agent to a new branch context and emits agent.switched', async () => { - const session = createTestSession(createEchoRequester()); - session.send({ type: 'agent.create', agentId: 'main' }); - submit(session, 'main', 'hi'); - await waitIdle(agentRef(session, 'main'), 2); - - const switched: Array<{ agentId: string; branchId: string; reason?: string }> = []; - session.on('agent.switched', (event) => { - if (event.type === 'agent.switched') { - switched.push({ agentId: event.agentId, branchId: event.branchId, reason: event.reason }); - } - }); - - session.send({ - type: 'agent.switch', - agentId: 'main', - input: { branchId: 'main~2', history: [], turnId: 0, reason: 'undo' }, - }); - - expect(switched).toEqual([{ agentId: 'main', branchId: 'main~2', reason: 'undo' }]); - const snapshot = agentRef(session, 'main').getSnapshot(); - expect(snapshot.context.branchId).toBe('main~2'); - expect(snapshot.context.messages).toEqual([]); - expect(snapshot.context.turnId).toBe(0); - }); - - it('emits agent.failed when switching an unknown or busy agent', async () => { - let release: (() => void) | undefined; - const requester: LlmRequester = { - generate: (_config, _content, { onEvent }) => - new Promise((resolve) => { - release = () => { - onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: 'late' } }); - onEvent?.({ type: 'llm.done' }); - resolve(); - }; - }), - }; - const session = createTestSession(requester); - const errors: string[] = []; - session.on('agent.failed', (event) => errors.push(event.error)); - session.send({ type: 'agent.create', agentId: 'main' }); - - session.send({ - type: 'agent.switch', - agentId: 'nope', - input: { branchId: 'x', history: [], turnId: 0 }, - }); - - submit(session, 'main', 'hi'); - await vi.waitFor(() => expect(release).toBeDefined()); - session.send({ - type: 'agent.switch', - agentId: 'main', - input: { branchId: 'main~2', history: [], turnId: 0 }, - }); - - expect(errors).toEqual([`unknown agent: 'nope'`, `agent is busy: 'main'`]); - expect(agentRef(session, 'main').getSnapshot().context.branchId).toBe('main'); - release?.(); - }); -}); diff --git a/packages/agent-core-v2/src/human/test/session/migrate-v2.test.ts b/packages/agent-core-v2/src/human/test/session/migrate-v2.test.ts index 723dbad1daa..a0e3d4c74ff 100644 --- a/packages/agent-core-v2/src/human/test/session/migrate-v2.test.ts +++ b/packages/agent-core-v2/src/human/test/session/migrate-v2.test.ts @@ -9,13 +9,14 @@ import { createActor, waitFor } from '#/xstate2'; import { UNKNOWN_CAPABILITY } from '#/llm/capability'; import { createUserMessage, extractText } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; -import { createLlmMachine } from '#/llm/requester/machine'; import type { LlmRequester } from '#/llm/requester/requester'; import { createAgentMachine } from '#/agent/machine'; -import { createTurnMachine } from '#/agent/turn'; +import type { StateUpdated } from '#/agent/events'; +import type { AgentEventStore, TurnIndexState } from '#/agent/slices'; +import { createTurnMachine, type HistoryMessage } from '#/agent/turn'; import { createSessionMachine, type AgentActorRef } from '#/session/machine'; -import { undoAgentTurns } from '#/session/undo'; -import { loadSessionState, persistSession } from '#/persist/session'; +import type { SessionStores } from '#/session/stores'; +import { createSlice } from '#/eventStore/slice'; import { openSessionStore } from '#/persist/open'; import { migrateV2Session } from '#/persist/v2/migrate'; @@ -42,7 +43,7 @@ function createTestSession() { createSessionMachine({ agent: createAgentMachine({ tools: [], - turnActor: createTurnMachine(createLlmMachine({ requester: createEchoRequester() })), + turnActor: createTurnMachine(createEchoRequester()), }), }), { input: { request: { model } } }, @@ -153,10 +154,55 @@ function assistantStep(uuid: string, text: string, agentId = MAIN): Record>> { +const statesSlice = createSlice({ + name: 'states', + initialState: () => ({}) as Record, + reducers: { + 'state.updated': (draft, event: StateUpdated) => { + draft[event.name] = event.value; + }, + }, +}); + +interface LoadedAgent { + agentId: string; + messages: HistoryMessage[]; + turnIndex: TurnIndexState; + states: Record; +} + +function readStates(store: AgentEventStore): Record { + return (store.getState() as unknown as Record>)['states'] ?? {}; +} + +async function loadAgent(stores: SessionStores, agentId: string): Promise { + const store = await stores.open(agentId); + if ((store.getState() as unknown as Record)['states'] === undefined) { + await store.registerSlice(statesSlice); + } + return { + agentId, + messages: store.getState().history, + turnIndex: store.getState().turnIndex, + states: readStates(store), + }; +} + +async function loadAgents(stores: SessionStores): Promise { + const roster = (await stores.session()).getState().roster.agents; + const agents: LoadedAgent[] = []; + for (const agentId of Object.keys(roster).toSorted()) { + agents.push(await loadAgent(stores, agentId)); + } + return agents; +} + +async function loadMigrated(dir: string) { await migrateV2Session(dir); const opened = await openSessionStore(dir); - return loadSessionState(opened.tree); + const agents = await loadAgents(opened.stores); + const meta = (await opened.stores.session()).getState().sessionMeta.value; + return { agents, meta }; } describe('migrateV2Session', () => { @@ -236,7 +282,7 @@ describe('migrateV2Session', () => { if (second?.message.role === 'assistant') { expect(second.meta.finish).toEqual({ finishReason: 'completed', rawFinishReason: null }); } - expect(agent.turnId).toBe(0); + expect(agent.turnIndex.nextTurnId).toBe(1); expect(loaded.meta).toMatchObject({ id: 'session_basic', version: 2, @@ -523,37 +569,41 @@ describe('migrateV2Session', () => { const first = await openSessionStore(dir); expect(first.migrated).toBe(true); - const loaded = await loadSessionState(first.tree); + const agentStore = await first.stores.open(MAIN); + expect(agentStore.getState().history.map((entry) => extractText(entry.message))).toEqual([ + 'first', + 'first-reply', + ]); + expect(agentStore.getState().turnIndex.nextTurnId).toBe(1); + const session = createTestSession(); - const persistence = persistSession(session, first.tree, { - branches: new Map(loaded.agents.map((agent) => [agent.agentId, agent.branch])), - }); - for (const agent of loaded.agents) { - session.send({ - type: 'agent.create', - agentId: agent.agentId, - input: { history: agent.messages, turnId: agent.turnId, branchId: agent.branch }, - }); - } + session.send({ type: 'agent.create', agentId: MAIN, input: { store: agentStore } }); submit(session, MAIN, 'again'); - await waitFor(agentRef(session, MAIN), (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); + await waitFor( + agentRef(session, MAIN), + (s) => s.matches('idle') && agentStore.getState().history.length === 4, + { timeout: 5000 }, + ); + await first.stores.flush(); - const undone = await undoAgentTurns(session, first.tree, MAIN, 1); - expect(undone.messages.map((entry) => extractText(entry.message))).toEqual(['first', 'first-reply']); - await persistence.flush(); - persistence.dispose(); + const undone = await first.stores.undo(MAIN, 1); + expect(undone.branchId).toBe('main~2'); + expect(agentStore.ref.branch).toBe('main~2'); + expect(agentStore.getState().history.map((entry) => extractText(entry.message))).toEqual([ + 'first', + 'first-reply', + 'again', + ]); + expect((await first.stores.session()).getState().roster.agents[MAIN]).toBe('main~2'); + await first.stores.flush(); session.stop(); + await first.stores.dispose(); const second = await openSessionStore(dir); expect(second.migrated).toBe(false); - const reloaded = await loadSessionState(second.tree); - expect(reloaded.agents.map((agent) => agent.agentId)).toEqual([MAIN]); - expect(reloaded.agents[0]!.messages.map((entry) => extractText(entry.message))).toEqual([ - 'first', - 'first-reply', - ]); + const roster = (await second.stores.session()).getState().roster.agents; + expect(Object.keys(roster)).toEqual([MAIN]); + expect(roster[MAIN]).toBe('main~2'); const names = await readdir(dir); expect(names.filter((name) => name.startsWith('.migrate'))).toEqual([]); expect(names).toContain('state.json'); diff --git a/packages/agent-core-v2/src/human/test/session/persist.test.ts b/packages/agent-core-v2/src/human/test/session/persist.test.ts deleted file mode 100644 index 88a539c5f31..00000000000 --- a/packages/agent-core-v2/src/human/test/session/persist.test.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { createActor, waitFor, type ActorRefFrom } from '#/xstate2'; - -import { UNKNOWN_CAPABILITY } from '#/llm/capability'; -import { createAssistantMessage, createUserMessage, extractText, type AssistantMessage, type Message, type ToolCall } from '#/llm/message'; -import type { LlmModel } from '#/llm/model'; -import { createLlmMachine } from '#/llm/requester/machine'; -import type { LlmRequester } from '#/llm/requester/requester'; -import { createAgentMachine } from '#/agent/machine'; -import { createTurnMachine, toInputMessages, type HistoryMessage } from '#/agent/turn'; -import { - createSessionMachine, - type AgentActorRef, -} from '#/session/machine'; -import { undoAgentTurns } from '#/session/undo'; -import { loadSessionState, persistSession } from '#/persist/session'; -import { collectPluginTools, connectPlugins } from '#/plugin'; -import { createTodoPlugin } from '#/todo/plugin'; -import { restoreTodoState, snapshotTodoState } from '#/todo/state'; -import { MemoryBackend } from '#/store/backend/memory'; -import type { Branch } from '#/store/branch'; -import type { Tree } from '#/store/tree'; -import { TreeStore } from '#/store/store'; - -const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; - -type SessionActor = ActorRefFrom>; - -function toolCall(id: string, name: string, args: string = '{}'): ToolCall { - return { type: 'function', id, name, arguments: args }; -} - -function createStubRequester(responses: readonly AssistantMessage[]): LlmRequester { - let call = 0; - return { - generate: (_config, _content, { onEvent }) => { - const message = responses[Math.min(call, responses.length - 1)]; - call += 1; - for (const part of [...message.content, ...message.toolCalls]) { - onEvent?.({ type: 'llm.streaming.part', part }); - } - onEvent?.({ type: 'llm.done' }); - return Promise.resolve(); - }, - }; -} - -function createEchoRequester(): LlmRequester { - return { - generate: (_config, { messages }, { onEvent }) => { - const last = messages.at(-1); - const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; - onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: `echo:${text}` } }); - onEvent?.({ type: 'llm.done' }); - return Promise.resolve(); - }, - }; -} - -function createTestSession(requester: LlmRequester): SessionActor { - const session = createActor( - createSessionMachine({ - agent: createAgentMachine({ - tools: [], - turnActor: createTurnMachine(createLlmMachine({ requester })), - }), - }), - { input: { request: { model } } }, - ); - session.start(); - return session; -} - -function agentRef(session: SessionActor, agentId: string): AgentActorRef { - const entry = session.getSnapshot().context.agents[agentId]; - expect(entry).toBeDefined(); - return (entry as { ref: AgentActorRef }).ref; -} - -function submit(session: SessionActor, agentId: string, text: string): void { - session.send({ - type: 'agent.send', - agentId, - event: { type: 'input.submit', message: createUserMessage(text) }, - }); -} - -function chainMessages(branch: Branch): Message[] { - return toInputMessages( - [...branch.walk()] - .toReversed() - .filter((entry) => entry.type === 'message') - .map((entry) => entry.payload.data as HistoryMessage), - ); -} - -async function restoreSession( - tree: Tree, -): Promise<{ - session: SessionActor; - loaded: Awaited>; - persistence: ReturnType; -}> { - const loaded = await loadSessionState(tree); - const session = createTestSession(createEchoRequester()); - const persistence = persistSession(session, tree, { - branches: new Map(loaded.agents.map((agent) => [agent.agentId, agent.branch])), - }); - for (const agent of loaded.agents) { - session.send({ - type: 'agent.create', - agentId: agent.agentId, - input: { history: agent.messages, turnId: agent.turnId, branchId: agent.branch }, - }); - } - return { session, loaded, persistence }; -} - -describe('persistSession', () => { - it('persists and restores a full session with a forked agent', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const tree = await store.tree('sess'); - const session = createTestSession(createEchoRequester()); - const persistence = persistSession(session, tree); - - session.send({ type: 'agent.create', agentId: 'main' }); - submit(session, 'main', 'hi'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { - timeout: 5000, - }); - await persistence.flush(); - - session.send({ type: 'agent.fork', sourceId: 'main', agentId: 'fork' }); - submit(session, 'fork', 'fork-hi'); - submit(session, 'main', 'main-hi'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - await waitFor(agentRef(session, 'fork'), (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - await persistence.flush(); - - expect(tree.branches()).toEqual(['_session', 'fork', 'main']); - const mainBranch = tree.openBranch('main'); - const forkBranch = tree.openBranch('fork'); - expect(forkBranch.header.parentBranch).toBe('main'); - expect(forkBranch.header.parentSeq).toBe(3); - expect(chainMessages(mainBranch).map(extractText)).toEqual([ - 'hi', - 'echo:hi', - 'main-hi', - 'echo:main-hi', - ]); - expect(chainMessages(forkBranch).map(extractText)).toEqual([ - 'hi', - 'echo:hi', - 'fork-hi', - 'echo:fork-hi', - ]); - expect(forkBranch.nextSeq).toBe(4); - - await persistence.flush(); - persistence.dispose(); - session.stop(); - - const reopened = await TreeStore.open(fs); - const { session: restored, loaded, persistence: restoredPersistence } = await restoreSession( - await reopened.tree('sess'), - ); - expect(loaded.agents.map((agent) => agent.agentId).sort()).toEqual(['fork', 'main']); - for (const agent of loaded.agents) { - expect(agent.turnId).toBe(2); - expect(agentRef(restored, agent.agentId).getSnapshot().context.messages).toEqual(agent.messages); - } - - submit(restored, 'main', 'again'); - await waitFor(agentRef(restored, 'main'), (s) => s.matches('idle') && s.context.messages.length === 6, { - timeout: 5000, - }); - await restoredPersistence.flush(); - const mainMessages = chainMessages((await reopened.tree('sess')).openBranch('main')); - expect(mainMessages.map(extractText)).toEqual([ - 'hi', - 'echo:hi', - 'main-hi', - 'echo:main-hi', - 'again', - 'echo:again', - ]); - }); - - it('skips cleanly stopped agents on restore', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const tree = await store.tree('sess'); - const session = createTestSession(createEchoRequester()); - const persistence = persistSession(session, tree); - - session.send({ type: 'agent.create', agentId: 'main' }); - session.send({ type: 'agent.create', agentId: 'temp' }); - submit(session, 'main', 'hi'); - submit(session, 'temp', 'temp-hi'); - await waitFor(agentRef(session, 'temp'), (s) => s.matches('idle') && s.context.messages.length === 2, { - timeout: 5000, - }); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { - timeout: 5000, - }); - session.send({ type: 'agent.stop', agentId: 'temp' }); - await persistence.flush(); - - const logEntries = [...tree.openBranch('_session').walk()] - .toReversed() - .map((entry) => `${entry.type}:${(entry.payload.data as { agentId: string }).agentId}`); - expect(logEntries).toEqual(['agent.open:main', 'agent.open:temp', 'agent.close:temp']); - - persistence.dispose(); - session.stop(); - - const reopened = await TreeStore.open(fs); - const loaded = await loadSessionState(await reopened.tree('sess')); - expect(loaded.agents.map((agent) => agent.agentId)).toEqual(['main']); - }); - - it('gives a recreated anonymous agent a fresh branch', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const tree = await store.tree('sess'); - const session = createTestSession(createEchoRequester()); - const persistence = persistSession(session, tree); - - session.send({ type: 'agent.create' }); - submit(session, 'agent-1', 'hi'); - await waitFor(agentRef(session, 'agent-1'), (s) => s.matches('idle') && s.context.messages.length === 2, { - timeout: 5000, - }); - session.send({ type: 'agent.stop', agentId: 'agent-1' }); - await persistence.flush(); - persistence.dispose(); - session.stop(); - - const reopened = await TreeStore.open(fs); - const loaded = await loadSessionState(await reopened.tree('sess')); - expect(loaded.agents).toEqual([]); - - const restored = createTestSession(createEchoRequester()); - const restoredPersistence = persistSession(restored, await reopened.tree('sess')); - restored.send({ type: 'agent.create', input: { branchId: 'agent-1~2' } }); - submit(restored, 'agent-1', 'fresh'); - await waitFor(agentRef(restored, 'agent-1'), (s) => s.matches('idle') && s.context.messages.length === 2, { - timeout: 5000, - }); - await restoredPersistence.flush(); - - const branches = (await reopened.tree('sess')).branches(); - expect(branches).toEqual(['_session', 'agent-1', 'agent-1~2']); - expect(chainMessages((await reopened.tree('sess')).openBranch('agent-1~2')).map(extractText)).toEqual([ - 'fresh', - 'echo:fresh', - ]); - }); -}); - - -describe('undoAgentTurns', () => { - it('undoes the last turn by switching to a forked branch and restores it after reopen', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const tree = await store.tree('sess'); - const session = createTestSession(createEchoRequester()); - const persistence = persistSession(session, tree); - - session.send({ type: 'agent.create', agentId: 'main' }); - submit(session, 'main', 'first'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { - timeout: 5000, - }); - submit(session, 'main', 'second'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - await persistence.flush(); - - const result = await undoAgentTurns(session, tree, 'main', 1); - expect(result.branchId).toBe('main~2'); - expect(result.turnId).toBe(1); - expect(toInputMessages(result.messages).map(extractText)).toEqual(['first', 'echo:first']); - - const snapshot = agentRef(session, 'main').getSnapshot(); - expect(snapshot.context.branchId).toBe('main~2'); - expect(snapshot.context.turnId).toBe(1); - expect(snapshot.context.messages).toHaveLength(2); - - const undoBranch = tree.openBranch('main~2'); - expect(undoBranch.header.parentBranch).toBe('main'); - expect(undoBranch.header.parentSeq).toBe(3); - expect(chainMessages(tree.openBranch('main')).map(extractText)).toEqual([ - 'first', - 'echo:first', - 'second', - 'echo:second', - ]); - - submit(session, 'main', 'third'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - await persistence.flush(); - expect(chainMessages(undoBranch).map(extractText)).toEqual([ - 'first', - 'echo:first', - 'third', - 'echo:third', - ]); - expect(chainMessages(tree.openBranch('main')).map(extractText)).toHaveLength(4); - - const logEntries = [...tree.openBranch('_session').walk()].toReversed(); - expect(logEntries.map((entry) => entry.type)).toEqual(['agent.open', 'agent.switch']); - expect(logEntries[1]?.payload.data).toMatchObject({ - agentId: 'main', - branch: 'main~2', - reason: 'undo', - from: { branch: 'main', seq: 7 }, - }); - - persistence.dispose(); - session.stop(); - - const reopened = await TreeStore.open(fs); - const { session: restored, loaded } = await restoreSession(await reopened.tree('sess')); - expect(loaded.agents).toHaveLength(1); - expect(loaded.agents[0]?.branch).toBe('main~2'); - expect(loaded.agents[0]?.turnId).toBe(2); - expect(toInputMessages(loaded.agents[0]?.messages ?? []).map(extractText)).toEqual([ - 'first', - 'echo:first', - 'third', - 'echo:third', - ]); - expect(agentRef(restored, 'main').getSnapshot().context.branchId).toBe('main~2'); - }); - - it('rejects undo when anchors are insufficient or the agent is busy', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const tree = await store.tree('sess'); - let release: (() => void) | undefined; - const requester: LlmRequester = { - generate: (_config, _content, { onEvent }) => - new Promise((resolve) => { - release = () => { - onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: 'late' } }); - onEvent?.({ type: 'llm.done' }); - resolve(); - }; - }), - }; - const session = createTestSession(requester); - persistSession(session, tree); - session.send({ type: 'agent.create', agentId: 'main' }); - - await expect(undoAgentTurns(session, tree, 'main', 1)).rejects.toMatchObject({ - reason: 'insufficient', - }); - - submit(session, 'main', 'hi'); - await vi.waitFor(() => expect(release).toBeDefined()); - await expect(undoAgentTurns(session, tree, 'main', 1)).rejects.toMatchObject({ reason: 'busy' }); - release?.(); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle'), { timeout: 5000 }); - - await expect(undoAgentTurns(session, tree, 'main', 5)).rejects.toMatchObject({ - reason: 'insufficient', - }); - await expect(undoAgentTurns(session, tree, 'nope', 1)).rejects.toMatchObject({ - reason: 'unknown-agent', - }); - await expect(undoAgentTurns(session, tree, 'main', 0)).rejects.toMatchObject({ - reason: 'invalid-count', - }); - }); - - it('rolls back plugin state from the undo result', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const tree = await store.tree('sess'); - const todo = createTodoPlugin(); - const tools = collectPluginTools([todo]); - const requester = createStubRequester([ - createAssistantMessage( - [], - [ - toolCall( - 'call-todo-1', - 'TodoList', - JSON.stringify({ todos: [{ title: 'task a', status: 'in_progress' }] }), - ), - ], - ), - createAssistantMessage([{ type: 'text', text: 'noted' }]), - createAssistantMessage( - [], - [ - toolCall( - 'call-todo-2', - 'TodoList', - JSON.stringify({ todos: [{ title: 'task a', status: 'done' }] }), - ), - ], - ), - createAssistantMessage([{ type: 'text', text: 'done' }]), - ]); - const session = createActor( - createSessionMachine({ - agent: createAgentMachine({ - tools, - turnActor: createTurnMachine(createLlmMachine({ requester })), - }), - }), - { input: { request: { model } } }, - ); - session.start(); - persistSession(session, tree, { - states: () => ({ todo: () => snapshotTodoState(todo.state) }), - }); - session.send({ type: 'agent.create', agentId: 'main' }); - connectPlugins(agentRef(session, 'main'), [todo]); - - submit(session, 'main', 'hi'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - expect(todo.state.todos).toEqual([{ title: 'task a', status: 'in_progress' }]); - - submit(session, 'main', 'finish'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 8, { - timeout: 5000, - }); - expect(todo.state.todos).toEqual([{ title: 'task a', status: 'done' }]); - - const result = await undoAgentTurns(session, tree, 'main', 1); - expect(result.states['todo']).toMatchObject({ - todos: [{ title: 'task a', status: 'in_progress' }], - lastWriteTurn: 1, - }); - - Object.assign(todo.state, restoreTodoState(result.states['todo'], result.turnId)); - expect(todo.state.todos).toEqual([{ title: 'task a', status: 'in_progress' }]); - expect(todo.state.currentTurn).toBe(1); - }); - - it('undoes twice by descending the parent chain', async () => { - const fs = new MemoryBackend(); - const store = await TreeStore.open(fs); - const tree = await store.tree('sess'); - const session = createTestSession(createEchoRequester()); - const persistence = persistSession(session, tree); - - session.send({ type: 'agent.create', agentId: 'main' }); - submit(session, 'main', 'one'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { - timeout: 5000, - }); - submit(session, 'main', 'two'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { - timeout: 5000, - }); - submit(session, 'main', 'three'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 6, { - timeout: 5000, - }); - await persistence.flush(); - - const first = await undoAgentTurns(session, tree, 'main', 1); - expect(first.branchId).toBe('main~2'); - expect(toInputMessages(first.messages).map(extractText)).toEqual([ - 'one', - 'echo:one', - 'two', - 'echo:two', - ]); - - submit(session, 'main', 'three-alt'); - await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 6, { - timeout: 5000, - }); - await persistence.flush(); - - const second = await undoAgentTurns(session, tree, 'main', 2); - expect(second.branchId).toBe('main~3'); - expect(second.turnId).toBe(1); - expect(toInputMessages(second.messages).map(extractText)).toEqual(['one', 'echo:one']); - const secondBranch = tree.openBranch('main~3'); - expect(secondBranch.header.parentBranch).toBe('main'); - expect(secondBranch.header.parentSeq).toBe(3); - }); -}); diff --git a/packages/agent-core-v2/src/human/test/session/stores.test.ts b/packages/agent-core-v2/src/human/test/session/stores.test.ts new file mode 100644 index 00000000000..a6473c6e136 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/session/stores.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; +import { createActor, waitFor, type ActorRefFrom } from '#/xstate2'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createUserMessage, extractText } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import type { LlmRequester } from '#/llm/requester/requester'; +import { createAgentMachine } from '#/agent/machine'; +import { createTurnMachine } from '#/agent/turn'; +import type { AgentEventStore } from '#/agent/slices'; +import { SessionStores } from '#/session/stores'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; +import type { Tree } from '#/store/tree'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +type AgentActor = ActorRefFrom>; + +function createEchoRequester(): LlmRequester { + return { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; +} + +interface TestEnv { + backend: MemoryBackend; + tree: Tree; + stores: SessionStores; +} + +async function testEnv(): Promise { + const backend = new MemoryBackend(); + const store = await TreeStore.open(backend, {}); + const tree = await store.tree('sess'); + return { backend, tree, stores: new SessionStores(tree, backend) }; +} + +async function reopen(env: TestEnv): Promise { + await env.stores.flush(); + await env.stores.dispose(); + const store = await TreeStore.open(env.backend, {}); + const tree = await store.tree('sess'); + return { backend: env.backend, tree, stores: new SessionStores(tree, env.backend) }; +} + +function startAgent(store: AgentEventStore, requester: LlmRequester = createEchoRequester()): AgentActor { + const actor = createActor( + createAgentMachine({ + tools: [], + turnActor: createTurnMachine(requester), + }), + { input: { request: { model }, store } }, + ); + actor.start(); + return actor; +} + +async function runTurn(actor: AgentActor, store: AgentEventStore, text: string, historyLength: number): Promise { + actor.send({ type: 'input.submit', message: createUserMessage(text) }); + await waitFor(actor, (s) => s.matches('idle') && store.getState().history.length === historyLength, { + timeout: 5000, + }); +} + +function historyTexts(store: AgentEventStore): string[] { + return store.getState().history.map((entry) => extractText(entry.message)); +} + +describe('SessionStores open/fork', () => { + it('folds history and turnIndex for opened and forked agents, then diverges', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main); + await runTurn(actor, main, 'hi', 2); + await env.stores.flush(); + + const fork = await env.stores.fork('main', 'fork'); + expect(fork.ref.branch).toBe('fork'); + expect(historyTexts(fork)).toEqual(['hi', 'echo:hi']); + expect(fork.getState().turnIndex.nextTurnId).toBe(1); + const forkHeader = env.tree.openBranch('fork').header; + expect(forkHeader.parentBranch).toBe('main'); + expect(forkHeader.parentSeq).toBe(env.tree.openBranch('main').head); + + expect((await env.stores.session()).getState().roster.agents).toEqual({ + fork: 'fork', + main: 'main', + }); + + const forkActor = startAgent(fork); + await runTurn(forkActor, fork, 'fork-hi', 4); + await runTurn(actor, main, 'main-hi', 4); + + expect(historyTexts(fork)).toEqual(['hi', 'echo:hi', 'fork-hi', 'echo:fork-hi']); + expect(historyTexts(main)).toEqual(['hi', 'echo:hi', 'main-hi', 'echo:main-hi']); + expect(fork.getState().turnIndex.nextTurnId).toBe(2); + expect(main.getState().turnIndex.nextTurnId).toBe(2); + + forkActor.stop(); + actor.stop(); + }); + + it('removes the agent from the roster on close', async () => { + const env = await testEnv(); + await env.stores.open('main'); + await env.stores.open('temp'); + expect((await env.stores.session()).getState().roster.agents).toEqual({ + main: 'main', + temp: 'temp', + }); + + await env.stores.close('temp'); + + expect(env.stores.get('temp')).toBeUndefined(); + expect((await env.stores.session()).getState().roster.agents).toEqual({ main: 'main' }); + }); +}); + +describe('SessionStores undo', () => { + it('rolls back to the turn boundary, forks with a parent ref, and updates the roster', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main); + await runTurn(actor, main, 'first', 2); + await runTurn(actor, main, 'second', 4); + await env.stores.flush(); + const cutStart = main.getState().turnIndex.turns.at(-1)?.start; + expect(cutStart).toBeDefined(); + + const result = await env.stores.undo('main', 1); + + expect(result.branchId).toBe('main~2'); + expect(main.ref.branch).toBe('main~2'); + expect(historyTexts(main)).toEqual(['first', 'echo:first', 'second']); + expect(main.getState().queue).toEqual([]); + expect(main.getState().turnIndex.turns).toHaveLength(1); + expect(main.getState().turnIndex.nextTurnId).toBe(1); + const header = env.tree.openBranch('main~2').header; + expect(header.parentBranch).toBe('main'); + expect(header.parentSeq).toBe((cutStart as { seq: number }).seq - 1); + expect((await env.stores.session()).getState().roster.agents['main']).toBe('main~2'); + expect(env.tree.openBranch('main').head).toBe(11); + + await waitFor(actor, (s) => s.matches('idle'), { timeout: 5000 }); + await runTurn(actor, main, 'third', 5); + expect(historyTexts(main)).toEqual(['first', 'echo:first', 'second', 'third', 'echo:third']); + expect(env.tree.openBranch('main').head).toBe(11); + expect(main.getState().turnIndex.nextTurnId).toBe(2); + + actor.stop(); + }); + + it('rejects invalid counts, unknown agents, and insufficient turns', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main); + await runTurn(actor, main, 'hi', 2); + + await expect(env.stores.undo('main', 2)).rejects.toMatchObject({ reason: 'insufficient' }); + await expect(env.stores.undo('nope', 1)).rejects.toMatchObject({ reason: 'unknown-agent' }); + await expect(env.stores.undo('main', 0)).rejects.toMatchObject({ reason: 'invalid-count' }); + + actor.stop(); + }); +}); + +describe('SessionStores reopen', () => { + it('restores agent state from the branch after reopen', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main); + await runTurn(actor, main, 'first', 2); + await runTurn(actor, main, 'second', 4); + await env.stores.flush(); + const fork = await env.stores.fork('main', 'fork'); + const forkActor = startAgent(fork); + await runTurn(forkActor, fork, 'fork-hi', 6); + forkActor.stop(); + actor.stop(); + + const restored = await reopen(env); + + expect((await restored.stores.session()).getState().roster.agents).toEqual({ + fork: 'fork', + main: 'main', + }); + const restoredMain = await restored.stores.open('main'); + expect(historyTexts(restoredMain)).toEqual(['first', 'echo:first', 'second', 'echo:second']); + expect(restoredMain.getState().turnIndex.nextTurnId).toBe(2); + const restoredFork = await restored.stores.open('fork'); + expect(historyTexts(restoredFork)).toEqual([ + 'first', + 'echo:first', + 'second', + 'echo:second', + 'fork-hi', + 'echo:fork-hi', + ]); + expect(restoredFork.getState().turnIndex.nextTurnId).toBe(3); + + const actor2 = startAgent(restoredMain); + await runTurn(actor2, restoredMain, 'again', 6); + expect(historyTexts(restoredMain)).toEqual([ + 'first', + 'echo:first', + 'second', + 'echo:second', + 'again', + 'echo:again', + ]); + expect(restoredMain.getState().turnIndex.nextTurnId).toBe(3); + + actor2.stop(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/todo/plugin.test.ts b/packages/agent-core-v2/src/human/test/todo/plugin.test.ts index c74febeab19..34a687f0ec2 100644 --- a/packages/agent-core-v2/src/human/test/todo/plugin.test.ts +++ b/packages/agent-core-v2/src/human/test/todo/plugin.test.ts @@ -1,11 +1,23 @@ import { describe, expect, it } from 'vitest'; +import { turnStarted } from '#/agent/events'; +import { agentSlices, type AgentEventStore } from '#/agent/slices'; +import { createEventStoreSync } from '#/eventStore/eventStore'; +import { memoryJournal } from '#/eventStore/journal'; import { extractText, type SystemMessage, type ToolCall, type UserMessage } from '#/llm/message'; import type { AgentEmitted } from '#/agent/machine'; import { connectPlugins, type AgentPluginTarget, type Plugin } from '#/plugin'; import type { ToolExecuteInput } from '#/tool/executor'; import type { ToolDefinition } from '#/tool/tool'; import { createTodoPlugin, type TodoPlugin } from '#/todo/plugin'; +import { todoSlice } from '#/todo/slice'; + +function testStore(): AgentEventStore { + return createEventStoreSync({ + journal: memoryJournal(), + slices: { ...agentSlices, todo: todoSlice }, + }) as AgentEventStore; +} function toolCall(args: unknown): ToolCall { return { type: 'function', id: 'call-1', name: 'TodoList', arguments: JSON.stringify(args) }; @@ -21,7 +33,7 @@ function pluginTool(plugin: TodoPlugin): ToolDefinition { return tool; } -function createTarget() { +function createTarget(store: AgentEventStore) { const handlers: ((event: AgentEmitted) => void)[] = []; const notified: UserMessage[] = []; const reminded: { key: string; message: UserMessage | SystemMessage }[] = []; @@ -37,9 +49,12 @@ function createTarget() { reminded.push({ key, message }); }, }; - const turnStart = () => { + let turnId = 0; + const turnStart = async () => { + await store.dispatch(turnStarted({ turnId })); + turnId += 1; for (const handler of handlers) { - handler({ type: 'turn.started', turnId: handlers.length, branchId: 'main' }); + handler({ type: 'turn.started', turnId, branchId: 'main' }); } }; return { target, notified, reminded, turnStart }; @@ -47,13 +62,13 @@ function createTarget() { describe('todo plugin tool', () => { it('reads an empty list', async () => { - const plugin = createTodoPlugin(); + const plugin = createTodoPlugin(testStore()); const result = await pluginTool(plugin).execute(executeInput({})); expect(result.content).toEqual([{ type: 'text', text: 'Todo list is empty.' }]); }); it('replaces the list and reads it back', async () => { - const plugin = createTodoPlugin(); + const plugin = createTodoPlugin(testStore()); const result = await pluginTool(plugin).execute( executeInput({ todos: [ @@ -76,7 +91,7 @@ describe('todo plugin tool', () => { }); it('clears the list with an empty array', async () => { - const plugin = createTodoPlugin(); + const plugin = createTodoPlugin(testStore()); await pluginTool(plugin).execute(executeInput({ todos: [{ title: 'task a', status: 'pending' }] })); const result = await pluginTool(plugin).execute(executeInput({ todos: [] })); expect(result.content).toEqual([{ type: 'text', text: 'Todo list cleared.' }]); @@ -86,7 +101,7 @@ describe('todo plugin tool', () => { }); it('drops malformed items on write', async () => { - const plugin = createTodoPlugin(); + const plugin = createTodoPlugin(testStore()); await pluginTool(plugin).execute( executeInput({ todos: [{ title: 'task a' }, { title: 'task b', status: 'done' }, 'junk'], @@ -101,46 +116,49 @@ describe('todo plugin tool', () => { describe('todo plugin reminder', () => { it('notifies once when the list goes stale', async () => { - const plugin = createTodoPlugin(); - const { target, notified, turnStart } = createTarget(); + const store = testStore(); + const plugin = createTodoPlugin(store); + const { target, notified, turnStart } = createTarget(store); plugin.connect?.(target); - turnStart(); + await turnStart(); await pluginTool(plugin).execute(executeInput({ todos: [{ title: 'task a', status: 'pending' }] })); - turnStart(); + await turnStart(); expect(notified).toHaveLength(0); - turnStart(); + await turnStart(); expect(notified).toHaveLength(1); const text = extractText(notified[0]); expect(text).toContain(''); expect(text).toContain('[pending] task a'); - turnStart(); + await turnStart(); expect(notified).toHaveLength(1); }); it('stays silent when every item is done', async () => { - const plugin = createTodoPlugin(); - const { target, notified, turnStart } = createTarget(); + const store = testStore(); + const plugin = createTodoPlugin(store); + const { target, notified, turnStart } = createTarget(store); plugin.connect?.(target); - turnStart(); + await turnStart(); await pluginTool(plugin).execute(executeInput({ todos: [{ title: 'task a', status: 'done' }] })); - turnStart(); - turnStart(); + await turnStart(); + await turnStart(); expect(notified).toHaveLength(0); }); - it('stays silent while the list is empty', () => { - const plugin = createTodoPlugin(); - const { target, notified, turnStart } = createTarget(); + it('stays silent while the list is empty', async () => { + const store = testStore(); + const plugin = createTodoPlugin(store); + const { target, notified, turnStart } = createTarget(store); plugin.connect?.(target); - turnStart(); - turnStart(); - turnStart(); + await turnStart(); + await turnStart(); + await turnStart(); expect(notified).toHaveLength(0); }); }); @@ -148,7 +166,7 @@ describe('todo plugin reminder', () => { describe('connectPlugins notify channel', () => { it('maps target.notify to an input.notify event on the actor', () => { const sent: unknown[] = []; - const plugin = createTodoPlugin(); + const plugin = createTodoPlugin(testStore()); const probe: Plugin = { name: 'probe', tools: () => [], diff --git a/packages/agent-core-v2/src/human/test/tool-select/plugin.test.ts b/packages/agent-core-v2/src/human/test/tool-select/plugin.test.ts index ddfdb609b14..ed8900ccfd8 100644 --- a/packages/agent-core-v2/src/human/test/tool-select/plugin.test.ts +++ b/packages/agent-core-v2/src/human/test/tool-select/plugin.test.ts @@ -11,11 +11,15 @@ import { type UserMessage, } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; -import { createLlmMachine } from '#/llm/requester/machine'; import type { LlmRequestConfig, LlmRequester, LlmRequestEvent } from '#/llm/requester/requester'; import { connectPlugins, type AgentPluginTarget } from '#/plugin'; import { createAgentMachine, type AgentEmitted } from '#/agent/machine'; +import { agentSlices, type AgentEventStore } from '#/agent/slices'; import { createTurnMachine, type HistoryMessage } from '#/agent/turn'; +import { createEventStore } from '#/eventStore/eventStore'; +import { journalFromBranch } from '#/eventStore/journal'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; import type { ToolExecuteInput } from '#/tool/executor'; import { defineTool, type ToolDefinition } from '#/tool/tool'; import { @@ -41,6 +45,14 @@ function executeInput(name: string, args: unknown): ToolExecuteInput { return { toolCall: toolCall(name, args), signal: new AbortController().signal }; } +async function testStore(): Promise { + const backend = new MemoryBackend(); + const store = await TreeStore.open(backend, {}); + const tree = await store.tree('test'); + tree.createBranch('main'); + return createEventStore({ journal: journalFromBranch(tree.openBranch('main'), tree), slices: agentSlices }); +} + function weatherTool(execute?: ToolDefinition['execute']): ToolDefinition { return defineTool({ name: 'get_weather', @@ -288,19 +300,20 @@ describe('tool select agent flow', () => { }), state, ); + const store = await testStore(); const actor = createActor( createAgentMachine({ tools: [createSelectToolsTool(state), deferred], - turnActor: createTurnMachine(createLlmMachine({ requester })), + turnActor: createTurnMachine(requester), }), - { input: { request: { model } } }, + { input: { request: { model }, store } }, ); connectPlugins(actor, [plugin]); actor.start(); actor.send({ type: 'input.submit', message: createUserMessage('weather?') }); - const snapshot = await waitFor( + await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length > 1, + (s) => s.matches('idle') && store.getState().history.length > 1, { timeout: 5000 }, ); @@ -309,7 +322,7 @@ describe('tool select agent flow', () => { expect(firstTools).not.toContain('get_weather'); expect(executed).toEqual(['get_weather']); - const schemaEntry = snapshot.context.messages.find( + const schemaEntry = store.getState().history.find( (entry: HistoryMessage) => entry.message.role === 'system', ); expect(schemaEntry?.meta.key).toBe(DYNAMIC_TOOL_SCHEMA_REMINDER_KEY); diff --git a/packages/agent-core-v2/src/human/test/usage/machine.test.ts b/packages/agent-core-v2/src/human/test/usage/machine.test.ts index 098ddcc3212..1f1c1e3cbe1 100644 --- a/packages/agent-core-v2/src/human/test/usage/machine.test.ts +++ b/packages/agent-core-v2/src/human/test/usage/machine.test.ts @@ -5,16 +5,24 @@ import { connectPlugins } from '#/plugin'; import { UNKNOWN_CAPABILITY } from '#/llm/capability'; import { createUserMessage } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; -import { createLlmMachine } from '#/llm/requester/machine'; import type { LlmRequester } from '#/llm/requester/requester'; import type { TokenUsage } from '#/llm/usage'; import { createAgentMachine } from '#/agent/machine'; +import { agentSlices, type AgentEventStore } from '#/agent/slices'; import { createTurnMachine } from '#/agent/turn'; +import { createEventStore } from '#/eventStore/eventStore'; +import { journalFromBranch } from '#/eventStore/journal'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; import { createUsageMachine } from '#/usage/machine'; import type { UsageEmitted } from '#/usage/machine'; import { createUsagePlugin } from '#/usage/plugin'; import type { UsageRecord } from '#/usage/usage'; import { createTimingPlugin } from '#/timing/plugin'; +import { + xstateInspectionCollector, + type XstateInspectionEnvelope, +} from '#/xstateInspection'; const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; @@ -30,32 +38,39 @@ function record( return { usage: usage(inputOther, output), model: extra?.model, turnId: extra?.turnId, at: 0 }; } -describe('usage machine', () => { - it('accumulates total, byModel and byTurn across usage.record events', () => { - const actor = createActor(createUsageMachine()); - actor.start(); - - actor.send({ type: 'usage.record', record: record(10, 2, { model, turnId: 1 }) }); - actor.send({ type: 'usage.record', record: record(5, 3, { model, turnId: 2 }) }); - actor.send({ type: 'usage.record', record: record(100, 0) }); +async function testStore(): Promise { + const backend = new MemoryBackend(); + const store = await TreeStore.open(backend, {}); + const tree = await store.tree('test'); + tree.createBranch('main'); + return createEventStore({ journal: journalFromBranch(tree.openBranch('main'), tree), slices: agentSlices }); +} - const { records, summary } = actor.getSnapshot().context; - expect(records).toHaveLength(3); - expect(summary.total).toEqual({ - inputOther: 115, - output: 5, - inputCacheRead: 0, - inputCacheCreation: 0, - }); - expect(summary.byModel).toEqual({ - 'test-model': { inputOther: 15, output: 5, inputCacheRead: 0, inputCacheCreation: 0 }, - }); - expect(summary.byTurn).toEqual({ - 1: { inputOther: 10, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, - 2: { inputOther: 5, output: 3, inputCacheRead: 0, inputCacheCreation: 0 }, - }); +describe('xstate inspection collector', () => { + it('publishes JSON-safe scalar envelopes with no machine context', () => { + const envelopes: XstateInspectionEnvelope[] = []; + const unsubscribe = xstateInspectionCollector.subscribe((envelope) => envelopes.push(envelope)); + try { + const actor = createActor(createUsageMachine()); + actor.start(); + actor.send({ type: 'usage.record', record: record(10, 2, { model, turnId: 1 }) }); + } finally { + unsubscribe(); + } + const delivered = envelopes.filter((envelope) => envelope.eventType === 'usage.record'); + expect(delivered.length).toBeGreaterThan(0); + for (const envelope of delivered) { + expect(typeof envelope.actorSessionId).toBe('string'); + expect(typeof envelope.timestamp).toBe('number'); + } + expect(delivered.find((envelope) => envelope.type === '@xstate.microstep')?.stateValue).toBeDefined(); + const serialized = JSON.stringify(envelopes); + expect(serialized).not.toContain('inputOther'); + expect(JSON.parse(serialized)).toEqual(envelopes); }); +}); +describe('usage machine', () => { it('groups byModel by baseUrl + model, ignoring provider', () => { const actor = createActor(createUsageMachine()); actor.start(); @@ -114,11 +129,12 @@ describe('usage plugin', () => { }; const plugin = createUsagePlugin({ model }); const timingPlugin = createTimingPlugin({ now: () => ticks.shift() ?? Number.NaN }); + const store = await testStore(); const actor = createActor( createAgentMachine({ - turnActor: createTurnMachine(createLlmMachine({ requester })), + turnActor: createTurnMachine(requester), }), - { input: { request: { model } } }, + { input: { request: { model }, store } }, ); connectPlugins(actor, [plugin, timingPlugin]); actor.start(); @@ -126,13 +142,13 @@ describe('usage plugin', () => { actor.send({ type: 'input.submit', message: createUserMessage('again') }); await waitFor( actor, - (s) => s.matches('idle') && s.context.messages.length === 4, + (s) => s.matches('idle') && store.getState().history.length === 4, { timeout: 5000 }, ); const { records, summary } = plugin.actor.getSnapshot().context; expect(records).toHaveLength(2); - expect(records.map((r) => r.turnId)).toEqual([1, 2]); + expect(records.map((r) => r.turnId)).toEqual([0, 1]); expect(records.map((r) => r.model)).toEqual([model, model]); expect(summary.total).toEqual({ inputOther: 20, @@ -141,13 +157,13 @@ describe('usage plugin', () => { inputCacheCreation: 0, }); expect(summary.byModel['test-model']).toEqual(summary.total); - expect(summary.byTurn[1]).toEqual({ + expect(summary.byTurn[0]).toEqual({ inputOther: 10, output: 2, inputCacheRead: 0, inputCacheCreation: 0, }); - expect(summary.byTurn[2]).toEqual(summary.byTurn[1]); + expect(summary.byTurn[1]).toEqual(summary.byTurn[0]); expect(timingPlugin.timing()).toEqual({ requestBuildMs: 100, diff --git a/packages/agent-core-v2/src/human/todo/plugin.ts b/packages/agent-core-v2/src/human/todo/plugin.ts index 0847ab14055..139c9d250ab 100644 --- a/packages/agent-core-v2/src/human/todo/plugin.ts +++ b/packages/agent-core-v2/src/human/todo/plugin.ts @@ -1,7 +1,8 @@ +import type { AgentEventStore } from '#/agent/slices'; import { createUserMessage } from '#/llm/message'; import type { Plugin } from '#/plugin'; -import { createTodoState, type TodoState } from './state'; +import { readTodoState } from './slice'; import { createTodoListTool } from './tool'; import { renderTodoList } from './todoItem'; @@ -9,26 +10,24 @@ const STALE_TURNS = 2; export interface TodoPlugin extends Plugin { readonly name: 'todo'; - readonly state: TodoState; } -export function createTodoPlugin(state: TodoState = createTodoState()): TodoPlugin { - const tool = createTodoListTool(state); +export function createTodoPlugin(store: AgentEventStore): TodoPlugin { + const tool = createTodoListTool(store); return { name: 'todo', - state, tools: () => [tool], connect(target) { if (target.kind !== 'agent') return; target.on('turn.started', (event) => { if (event.type !== 'turn.started') return; - state.currentTurn += 1; - if (state.todos.length === 0) return; - if (state.todos.every((todo) => todo.status === 'done')) return; - if (state.currentTurn - state.lastWriteTurn !== STALE_TURNS) return; + const { todos, currentTurn, lastWriteTurn } = readTodoState(store); + if (todos.length === 0) return; + if (todos.every((todo) => todo.status === 'done')) return; + if (currentTurn - lastWriteTurn !== STALE_TURNS) return; target.notify( createUserMessage( - `\nThe todo list has not been updated recently. If the work is still in progress, update the list to reflect the current progress.\n${renderTodoList(state.todos)}\n`, + `\nThe todo list has not been updated recently. If the work is still in progress, update the list to reflect the current progress.\n${renderTodoList(todos)}\n`, ), ); }); diff --git a/packages/agent-core-v2/src/human/todo/slice.ts b/packages/agent-core-v2/src/human/todo/slice.ts new file mode 100644 index 00000000000..57a2a718b9d --- /dev/null +++ b/packages/agent-core-v2/src/human/todo/slice.ts @@ -0,0 +1,34 @@ +import { createSlice } from '#/eventStore/slice'; + +import { stateUpdated, turnStarted, type StateUpdated, type TurnStarted } from '#/agent/events'; + +import { readTodoItems, type TodoItem } from './todoItem'; + +export interface TodoSliceState { + todos: readonly TodoItem[]; + currentTurn: number; + lastWriteTurn: number; +} + +export function readTodoState(store: { slice(name: string): unknown }): TodoSliceState { + const state = store.slice('todo') as TodoSliceState | undefined; + return state ?? { todos: [], currentTurn: 0, lastWriteTurn: 0 }; +} + +export const todoSlice = createSlice({ + name: 'todo', + initialState: (): TodoSliceState => ({ todos: [], currentTurn: 0, lastWriteTurn: 0 }), + reducers: { + [stateUpdated.type]: (draft, event: StateUpdated) => { + if (event.name !== 'todo') return; + const value = event.value as { todos?: unknown; lastWriteTurn?: unknown }; + draft.todos = readTodoItems(value.todos); + if (typeof value.lastWriteTurn === 'number') { + draft.lastWriteTurn = value.lastWriteTurn; + } + }, + [turnStarted.type]: (draft, _event: TurnStarted) => { + draft.currentTurn += 1; + }, + }, +}); diff --git a/packages/agent-core-v2/src/human/todo/state.ts b/packages/agent-core-v2/src/human/todo/state.ts deleted file mode 100644 index dae96de98bb..00000000000 --- a/packages/agent-core-v2/src/human/todo/state.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readTodoItems, type TodoItem } from './todoItem'; - -export interface TodoState { - todos: readonly TodoItem[]; - currentTurn: number; - lastWriteTurn: number; -} - -export function createTodoState(): TodoState { - return { todos: [], currentTurn: 0, lastWriteTurn: 0 }; -} - -export interface PersistedTodoState { - todos: readonly TodoItem[]; - lastWriteTurn: number; -} - -export function snapshotTodoState(state: TodoState): PersistedTodoState { - return { todos: state.todos, lastWriteTurn: state.lastWriteTurn }; -} - -export function restoreTodoState(value: unknown, currentTurn: number): TodoState { - const state = createTodoState(); - state.currentTurn = currentTurn; - if (typeof value === 'object' && value !== null) { - const record = value as Record; - state.todos = readTodoItems(record['todos']); - if (typeof record['lastWriteTurn'] === 'number') { - state.lastWriteTurn = record['lastWriteTurn']; - } - } - return state; -} diff --git a/packages/agent-core-v2/src/human/todo/tool.ts b/packages/agent-core-v2/src/human/todo/tool.ts index df232d5f255..b042a40ce20 100644 --- a/packages/agent-core-v2/src/human/todo/tool.ts +++ b/packages/agent-core-v2/src/human/todo/tool.ts @@ -1,11 +1,13 @@ +import { stateUpdated } from '#/agent/events'; +import type { AgentEventStore } from '#/agent/slices'; import { defineTool, type ToolDefinition } from '#/tool/tool'; -import type { TodoState } from './state'; import { readTodoItems, renderTodoList, TODO_LIST_TOOL_NAME } from './todoItem'; +import { readTodoState } from './slice'; import DESCRIPTION from './todo-list.md?raw'; import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw'; -export function createTodoListTool(state: TodoState): ToolDefinition { +export function createTodoListTool(store: AgentEventStore): ToolDefinition { return defineTool({ name: TODO_LIST_TOOL_NAME, description: DESCRIPTION, @@ -34,11 +36,15 @@ export function createTodoListTool(state: TodoState): ToolDefinition { async execute({ toolCall }) { const args = JSON.parse(toolCall.arguments ?? '{}') as { todos?: unknown }; if (args.todos === undefined) { - return { content: [{ type: 'text', text: renderTodoList(state.todos) }] }; + return { content: [{ type: 'text', text: renderTodoList(readTodoState(store).todos) }] }; } const next = readTodoItems(args.todos); - state.todos = next; - state.lastWriteTurn = state.currentTurn; + await store.dispatch( + stateUpdated({ + name: 'todo', + value: { todos: next, lastWriteTurn: readTodoState(store).currentTurn }, + }), + ); if (next.length === 0) { return { content: [{ type: 'text', text: 'Todo list cleared.' }] }; } diff --git a/packages/agent-core-v2/src/human/tool-select/resolver.ts b/packages/agent-core-v2/src/human/tool-select/resolver.ts index 9455c6c6cec..462ebcd024c 100644 --- a/packages/agent-core-v2/src/human/tool-select/resolver.ts +++ b/packages/agent-core-v2/src/human/tool-select/resolver.ts @@ -1,5 +1,5 @@ import type { Message } from '#/llm/message'; -import type { MessageResolver } from '#/llm/requester/machine'; +import type { MessageResolver } from '#/llm/requester/actor'; import type { ToolSelectState } from './state'; diff --git a/packages/agent-core-v2/src/human/xstate2.ts b/packages/agent-core-v2/src/human/xstate2.ts index 6a163310aac..e9f709a0150 100644 --- a/packages/agent-core-v2/src/human/xstate2.ts +++ b/packages/agent-core-v2/src/human/xstate2.ts @@ -1,6 +1,8 @@ import { createActor as createXStateActor } from 'xstate'; import type { Actor, ActorOptions, AnyActorLogic, InspectionEvent } from 'xstate'; +import { xstateInspectionCollector } from '#/xstateInspection'; + export * from 'xstate'; function reportUnhandled(event: InspectionEvent): void { @@ -24,6 +26,7 @@ function createActorWithInspect( ...options, inspect: (event) => { reportUnhandled(event); + xstateInspectionCollector.publish(event); if (typeof inspect === 'function') { inspect(event); } else { diff --git a/packages/agent-core-v2/src/human/xstateInspection.ts b/packages/agent-core-v2/src/human/xstateInspection.ts new file mode 100644 index 00000000000..12671e09e4d --- /dev/null +++ b/packages/agent-core-v2/src/human/xstateInspection.ts @@ -0,0 +1,66 @@ +import type { InspectionEvent } from 'xstate'; + +export type XstateInspectionEventType = InspectionEvent['type']; + +export interface XstateInspectionEnvelope { + readonly type: XstateInspectionEventType; + readonly timestamp: number; + readonly actorSessionId: string; + readonly actorId?: string; + readonly logicId?: string; + readonly eventType?: string; + readonly stateValue?: unknown; +} + +export type XstateInspectionListener = (envelope: XstateInspectionEnvelope) => void; + +export interface XstateInspectionCollector { + subscribe(listener: XstateInspectionListener): () => void; + publish(event: InspectionEvent): void; +} + +function scalar(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function toEnvelope(event: InspectionEvent, now: () => number): XstateInspectionEnvelope { + const actorRef = event.actorRef as { id?: unknown; logic?: unknown }; + const logic = actorRef.logic as { id?: unknown } | undefined; + const snapshot = 'snapshot' in event ? (event.snapshot as { value?: unknown }) : undefined; + return { + type: event.type, + timestamp: now(), + actorSessionId: event.actorRef.sessionId, + actorId: scalar(actorRef.id), + logicId: scalar(logic?.id), + eventType: + 'event' in event + ? event.event.type + : event.type === '@xstate.action' + ? event.action.type + : undefined, + stateValue: snapshot?.value, + }; +} + +export function createXstateInspectionCollector(input?: { + now?: () => number; +}): XstateInspectionCollector { + const now = input?.now ?? Date.now; + const listeners = new Set(); + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + publish(event) { + if (listeners.size === 0) return; + const envelope = toEnvelope(event, now); + for (const listener of listeners) listener(envelope); + }, + }; +} + +export const xstateInspectionCollector = createXstateInspectionCollector(); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 9a445f1dae1..1b048449ecb 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -159,10 +159,9 @@ export type { export type { ThinkingEffort, ThinkingRequestOptions } from '#human/llm/thinking'; export type { VideoUploadInput } from '#human/llm/media/upload'; export type { ToolCallIdPolicy } from '#human/llm/requester/requester'; -export type { ProviderRequestAuth } from '#/llm-adapter/model/catalog'; export type { SamplingOptions } from '#/llm-adapter/model/model-requester'; export * from '#/llm-adapter/contract/request-trace'; -export type { ExtraBody, KimiThinkingConfig } from '#human/llm-kimi/trait'; +export type { KimiThinkingConfig } from '#human/llm-kimi/trait'; export * from '#/app/sessionIndex/sessionIndex'; export * from '#/app/sessionIndex/sessionIndexService'; @@ -361,8 +360,6 @@ export * from '#/app/flag/flagRegistryService'; export * from '#/app/flag/flag'; export * from '#/app/flag/flagService'; -export * from '#/agent/activityView/activityView'; -import '#/agent/activityView/activityViewService'; export * from '#/agent/modeMutex/modeMutex'; import '#/agent/modeMutex/modeMutexService'; export * from '#/features/btw/btw'; diff --git a/packages/agent-core-v2/src/llm-adapter/contract/inspection.ts b/packages/agent-core-v2/src/llm-adapter/contract/inspection.ts deleted file mode 100644 index 0cf0916ad15..00000000000 --- a/packages/agent-core-v2/src/llm-adapter/contract/inspection.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type InspectionSourceKind = - | 'config' - | 'override' - | 'builtin' - | 'env' - | 'synthesized' - | 'none'; - -export interface InspectionSource { - readonly kind: InspectionSourceKind; - readonly detail?: string; -} - -export interface ResolutionTrace { - record(path: string, source: InspectionSource): void; - capture(key: string, value: unknown): void; -} diff --git a/packages/agent-core-v2/src/llm-adapter/model/catalog-runtime.ts b/packages/agent-core-v2/src/llm-adapter/model/catalog-runtime.ts new file mode 100644 index 00000000000..9d6944d80e6 --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/model/catalog-runtime.ts @@ -0,0 +1,259 @@ +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { + createProviderCatalogSync, + type CatalogModel, + type CatalogModelDefinition, + type CatalogModelOverrides, + type CatalogProviderInfo, + type ProviderCatalog, + type ProviderCatalogChanged, +} from '#human/llm/provider-catalog'; +import type { ModelCapability } from '#human/llm/capability'; + +import { deepEqual } from '../record-diff'; +import { IProviderService, type ProviderConfig } from '../provider/provider'; + +import { IModelService, type ModelOverride, type ModelRecord } from './model'; +import { deriveProviderId, nonEmpty } from './model-auth'; + +interface CatalogModelExtras { + readonly record: ModelRecord; +} + +interface DesiredBucket { + readonly info?: CatalogProviderInfo; + readonly models: readonly CatalogModelDefinition[]; +} + +export interface IProviderCatalogRuntime { + readonly _serviceBrand: undefined; + + sync(): void; + resync(): void; + aliases(): readonly string[]; + providerIds(): readonly string[]; + providerInfo(providerId: string): CatalogProviderInfo | undefined; + lookup(alias: string): CatalogModel | undefined; + onChanged(listener: (event: ProviderCatalogChanged) => void): IDisposable; +} + +export const IProviderCatalogRuntime: ServiceIdentifier = + createDecorator('providerCatalogRuntime'); + +export function rawRecordOf(definition: CatalogModelDefinition): ModelRecord { + return (definition.extras as unknown as CatalogModelExtras).record; +} + +export function bucketOfRecord(record: ModelRecord, defaultProvider: string | undefined): string { + const referenced = record.providerId ?? record.provider ?? defaultProvider; + if (referenced !== undefined) return referenced; + return deriveProviderId(nonEmpty(record.baseUrl) ?? ''); +} + +export function toCatalogProviderInfo(config: ProviderConfig): CatalogProviderInfo { + return { ...config }; +} + +export function toCatalogModelDefinition( + alias: string, + record: ModelRecord, + bucket: string, +): CatalogModelDefinition { + return { + provider: bucket, + model: alias, + capability: capabilityFromDeclared(record.capabilities), + maxContextSize: record.maxContextSize, + maxInputSize: record.maxInputSize, + baseUrl: record.baseUrl, + apiKey: record.apiKey, + displayName: record.displayName, + maxOutputSize: record.maxOutputSize, + reasoningKey: record.reasoningKey, + supportEfforts: record.supportEfforts, + offEffort: record.offEffort, + alwaysThinking: declaresAlwaysThinking(record.capabilities), + protocol: record.protocol, + defaultEffort: record.defaultEffort, + adaptiveThinking: record.adaptiveThinking, + betaApi: record.betaApi, + name: record.name, + aliases: record.aliases, + oauth: record.oauth, + overrides: toCatalogOverrides(record.overrides), + extras: { record }, + }; +} + +function toCatalogOverrides(overrides: ModelOverride | undefined): CatalogModelOverrides | undefined { + if (overrides === undefined) return undefined; + const out: { + -readonly [K in keyof CatalogModelOverrides]?: CatalogModelOverrides[K]; + } = {}; + if (overrides.maxContextSize !== undefined) out.maxContextSize = overrides.maxContextSize; + if (overrides.maxInputSize !== undefined) out.maxInputSize = overrides.maxInputSize; + if (overrides.maxOutputSize !== undefined) out.maxOutputSize = overrides.maxOutputSize; + if (overrides.displayName !== undefined) out.displayName = overrides.displayName; + if (overrides.reasoningKey !== undefined) out.reasoningKey = overrides.reasoningKey; + if (overrides.adaptiveThinking !== undefined) out.adaptiveThinking = overrides.adaptiveThinking; + if (overrides.supportEfforts !== undefined) out.supportEfforts = overrides.supportEfforts; + if (overrides.defaultEffort !== undefined) out.defaultEffort = overrides.defaultEffort; + if (overrides.offEffort !== undefined) out.offEffort = overrides.offEffort; + if (overrides.capabilities !== undefined) { + out.capability = capabilityFromDeclared(overrides.capabilities); + out.alwaysThinking = declaresAlwaysThinking(overrides.capabilities); + } + return out; +} + +function declaredSet(capabilities: readonly string[] | undefined): ReadonlySet { + return new Set((capabilities ?? []).map((capability) => capability.trim().toLowerCase())); +} + +function declaresAlwaysThinking(capabilities: readonly string[] | undefined): boolean { + return declaredSet(capabilities).has('always_thinking'); +} + +function capabilityFromDeclared(capabilities: readonly string[] | undefined): ModelCapability { + const declared = declaredSet(capabilities); + return { + image_in: declared.has('image_in'), + video_in: declared.has('video_in'), + audio_in: declared.has('audio_in'), + thinking: declared.has('thinking') || declared.has('always_thinking'), + tool_use: declared.has('tool_use'), + dynamically_loaded_tools: declared.has('dynamically_loaded_tools'), + }; +} + +export class ProviderCatalogRuntimeService extends Disposable implements IProviderCatalogRuntime { + declare readonly _serviceBrand: undefined; + + private readonly catalog: ProviderCatalog = createProviderCatalogSync(); + private readonly synced = new Map(); + private routing = new Map(); + private aliasOrder: readonly string[] = []; + private providerOrder: readonly string[] = []; + private dirty = true; + + constructor( + @IModelService private readonly modelService: IModelService, + @IProviderService private readonly providerService: IProviderService, + ) { + super(); + this._register( + this.modelService.onDidChangeModels(() => { + this.markDirty(); + }), + ); + this._register( + this.providerService.onDidChangeProviders(() => { + this.markDirty(); + }), + ); + this._register( + this.providerService.onDidChangeDefaultProvider(() => { + this.markDirty(); + }), + ); + this._register({ + dispose: () => { + this.catalog.stop(); + }, + }); + } + + sync(): void { + if (!this.dirty) return; + this.dirty = false; + this.syncAll(); + } + + resync(): void { + this.dirty = false; + this.syncAll(); + } + + aliases(): readonly string[] { + this.sync(); + return this.aliasOrder; + } + + providerIds(): readonly string[] { + this.sync(); + return this.providerOrder; + } + + providerInfo(providerId: string): CatalogProviderInfo | undefined { + this.sync(); + return this.catalog.providerInfo(providerId); + } + + lookup(alias: string): CatalogModel | undefined { + this.sync(); + const bucket = this.routing.get(alias); + if (bucket === undefined) return undefined; + return this.catalog.models(bucket).find((model) => model.model === alias); + } + + onChanged(listener: (event: ProviderCatalogChanged) => void): IDisposable { + const unsubscribe = this.catalog.onChanged(listener); + return { + dispose: () => { + unsubscribe(); + }, + }; + } + + private markDirty(): void { + this.dirty = true; + } + + private syncAll(): void { + const providers = this.providerService.list(); + const models = this.modelService.list(); + const defaultProvider = this.providerService.getDefaultProvider(); + const desired = new Map(); + for (const [providerId, config] of Object.entries(providers)) { + desired.set(providerId, { info: toCatalogProviderInfo(config), models: [] }); + } + const routing = new Map(); + for (const [alias, record] of Object.entries(models)) { + const bucket = bucketOfRecord(record, defaultProvider); + routing.set(alias, bucket); + let entry = desired.get(bucket); + if (entry === undefined) { + entry = { models: [] }; + desired.set(bucket, entry); + } + entry.models.push(toCatalogModelDefinition(alias, record, bucket)); + } + this.routing = routing; + this.aliasOrder = [...routing.keys()]; + this.providerOrder = Object.keys(providers); + + for (const [providerId, bucket] of desired) { + const next: DesiredBucket = { info: bucket.info, models: bucket.models }; + const previous = this.synced.get(providerId); + if (previous !== undefined && deepEqual(previous, next)) continue; + this.synced.set(providerId, next); + this.catalog.upsertEntry({ providerId, info: bucket.info, models: bucket.models }); + } + for (const providerId of this.synced.keys()) { + if (desired.has(providerId)) continue; + this.synced.delete(providerId); + this.catalog.remove(providerId); + } + } +} + +registerScopedService( + LifecycleScope.App, + IProviderCatalogRuntime, + ProviderCatalogRuntimeService, + ScopeActivation.OnScopeCreated, + 'providerCatalogRuntime', +); diff --git a/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts index 89749a94e21..4a154ff2a0d 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts @@ -4,12 +4,10 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; -import { - LATEST_OPUS_PROFILE, - matchKnownAnthropicModelProfile, - matchUnknownClaudeProfile, -} from '#human/llm/requester/bases/anthropic/profile'; +import type { CatalogModel, CatalogProviderInfo } from '#human/llm/provider-catalog'; +import { oauthCredentials, staticCredentials } from '#human/credentials/credentials'; +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; import type { ModelCapability } from '../contract/capability'; import { CONFIG_INVALID_ERROR_CODE } from '../contract/errors'; import type { TokenUsage } from '#human/llm/usage'; @@ -18,54 +16,49 @@ import { type Protocol, type ProtocolProviderOptions, } from '../protocol/protocol'; -import { - IProviderService, - type ProviderConfig, -} from '../provider/provider'; +import { IProviderService } from '../provider/provider'; import { getProviderDefinition, resolveProviderEndpoint, } from '../provider/provider-definition'; import { - type AuthProvider, IModelCatalog, type Model, type ModelCatalogItem, type ModelPingResult, type ProviderCatalogItem, type ProviderCredentialState, - type ProviderRequestAuth, type SetDefaultModelResponse, - StaticAuthProvider, toProtocolModel, toProtocolModelFallback, toProtocolProvider, } from './catalog'; +import { IProviderCatalogRuntime, rawRecordOf } from './catalog-runtime'; +import { + runWithCredentialRecovery, + streamWithCredentialRecovery, +} from './credential-recovery'; import { ModelCatalogErrors } from './errors'; import { IHostRequestHeaders } from './host-request-headers'; -import { - assembleModelInspection, - attributeEffectiveFields, - attributeProviderOptions, - type ModelInspection, - ResolutionTraceCollector, - TRACE, -} from './inspection'; import { IModelService, type ModelRecord } from './model'; import { deriveProviderId, - effectiveModelConfig, nonEmpty, resolveEndpointBaseUrl, resolveModelAuthMaterial, resolveModelProtocol, + withAnthropicProfile, } from './model-auth'; import { IModelOAuthTokens } from './model-oauth'; import type { ResolvedModelAuthMaterial } from './model.types'; -import type { ModelRequester } from './model-requester'; +import type { + ModelRequestEvent, + ModelRequestInput, + ModelRequestParams, + ModelRequester, +} from './model-requester'; import { ModelRequesterImpl } from './model-requester-impl'; -import { drivesThinkingThroughTraits } from './thinking'; type MutableProtocolProviderOptions = { -readonly [K in keyof ProtocolProviderOptions]: ProtocolProviderOptions[K]; @@ -74,7 +67,6 @@ type MutableProtocolProviderOptions = { interface CatalogEntry { readonly model: Model; readonly requester: ModelRequester; - readonly trace: ResolutionTraceCollector; } export class ModelCatalog extends Disposable implements IModelCatalog { @@ -83,6 +75,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { private readonly cache = new Map(); constructor( + @IProviderCatalogRuntime private readonly runtime: IProviderCatalogRuntime, @IProviderService private readonly providers: IProviderService, @IModelService private readonly models: IModelService, @IModelOAuthTokens private readonly oauth: IModelOAuthTokens, @@ -91,12 +84,25 @@ export class ModelCatalog extends Disposable implements IModelCatalog { @IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders, ) { super(); - this._register(this.models.onDidChangeModels(() => this.notifyConfigChanged())); - this._register(this.providers.onDidChangeProviders(() => this.notifyConfigChanged())); + this._register( + this.runtime.onChanged((event) => { + this.invalidate(event.providers); + }), + ); } notifyConfigChanged(): void { - this.cache.clear(); + this.runtime.resync(); + } + + private invalidate(providerIds: readonly string[]): void { + if (providerIds.length === 0) return; + const changed = new Set(providerIds); + for (const [alias, entry] of this.cache) { + if (changed.has(entry.model.providerName)) { + this.cache.delete(alias); + } + } } get(id: string): Model { @@ -109,57 +115,79 @@ export class ModelCatalog extends Disposable implements IModelCatalog { findByName(name: string): readonly string[] { const out: string[] = []; - for (const [id, m] of Object.entries(this.models.list())) { - const alias = m.name === name || m.model === name || (m.aliases ?? []).includes(name); - if (alias) out.push(id); + for (const alias of this.runtime.aliases()) { + const definition = this.runtime.lookup(alias); + if (definition === undefined) continue; + const record = rawRecordOf(definition); + if (record.name === name || record.model === name || (record.aliases ?? []).includes(name)) { + out.push(alias); + } } return out; } private entry(id: string): CatalogEntry { + this.runtime.sync(); const cached = this.cache.get(id); if (cached !== undefined) return cached; - const trace = new ResolutionTraceCollector(); - const model = this.buildModel(id, trace); + const model = this.buildModel(id); const entry: CatalogEntry = { model, requester: new ModelRequesterImpl(model, this.protocolRegistry), - trace, }; this.cache.set(id, entry); return entry; } - inspect(id: string): ModelInspection { - const { model, trace } = this.entry(id); - return assembleModelInspection({ id, model, trace }); + async *generate( + id: string, + input: ModelRequestInput, + signal?: AbortSignal, + params?: ModelRequestParams, + ): AsyncIterable { + const { requester } = this.entry(id); + yield* streamWithCredentialRecovery( + requester.model.credentials, + () => requester.request(input, signal, params), + signal, + ); } async ping(id: string): Promise { const { requester } = this.entry(id); const startedAt = Date.now(); try { - let text = ''; - let usage: TokenUsage | undefined; - let finishReason: string | undefined; - for await (const event of requester.request( - { - systemPrompt: 'You are a connectivity probe. Answer with the single word "pong".', - tools: [], - messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }], toolCalls: [] }], - }, - undefined, - { maxCompletionTokens: 512 }, - )) { - if (event.type === 'part' && event.part.type === 'text') { - text += event.part.text; - } else if (event.type === 'usage') { - usage = event.usage; - } else if (event.type === 'finish') { - finishReason = event.providerFinishReason ?? event.rawFinishReason; + const consume = async () => { + let text = ''; + let usage: TokenUsage | undefined; + let finishReason: string | undefined; + for await (const event of requester.request( + { + systemPrompt: 'You are a connectivity probe. Answer with the single word "pong".', + tools: [], + messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }], toolCalls: [] }], + }, + undefined, + { maxCompletionTokens: 512 }, + )) { + if (event.type === 'part' && event.part.type === 'text') { + text += event.part.text; + } else if (event.type === 'usage') { + usage = event.usage; + } else if (event.type === 'finish') { + finishReason = event.providerFinishReason ?? event.rawFinishReason; + } } - } - return { ok: true, durationMs: Date.now() - startedAt, text: text.trim(), finishReason, usage }; + return { text: text.trim(), usage, finishReason }; + }; + const result = await runWithCredentialRecovery(requester.model.credentials, consume); + return { + ok: true, + durationMs: Date.now() - startedAt, + text: result.text, + finishReason: result.finishReason, + usage: result.usage, + }; } catch (error) { return { ok: false, @@ -170,49 +198,58 @@ export class ModelCatalog extends Disposable implements IModelCatalog { } async listModels(): Promise { - const models = this.models.list(); - return Object.entries(models).map(([modelId, record]) => { + const out: ModelCatalogItem[] = []; + for (const modelId of this.runtime.aliases()) { + const definition = this.runtime.lookup(modelId); + if (definition === undefined) continue; + const record = rawRecordOf(definition); const providerType = this.providerTypeOf(record); try { - return toProtocolModel(this.get(modelId), record, providerType); + out.push(toProtocolModel(this.get(modelId), record, providerType)); } catch { - return toProtocolModelFallback(modelId, record, providerType); + out.push(toProtocolModelFallback(modelId, record, providerType)); } - }); + } + return out; } async listProviders(): Promise { - const providers = this.providers.list(); - const models = this.models.list(); + const records = this.allRecords(); const globalDefaultModel = this.models.getDefaultModel(); const out: ProviderCatalogItem[] = []; - for (const [providerId, provider] of Object.entries(providers)) { - out.push(await this.toCatalogProvider(providerId, provider, models, globalDefaultModel)); + for (const providerId of this.runtime.providerIds()) { + const provider = this.runtime.providerInfo(providerId); + if (provider === undefined) continue; + out.push(await this.toCatalogProvider(providerId, provider, records, globalDefaultModel)); } return out; } async getProvider(providerId: string): Promise { - const provider = this.providers.get(providerId); + const provider = this.runtime.providerInfo(providerId); if (provider === undefined) { throw new Error2( ModelCatalogErrors.codes.PROVIDER_NOT_FOUND, `provider ${providerId} does not exist`, ); } - const models = this.models.list(); - const globalDefaultModel = this.models.getDefaultModel(); - return this.toCatalogProvider(providerId, provider, models, globalDefaultModel); + return this.toCatalogProvider( + providerId, + provider, + this.allRecords(), + this.models.getDefaultModel(), + ); } async setDefaultModel(modelId: string): Promise { - const record = this.models.get(modelId); - if (record === undefined) { + const definition = this.runtime.lookup(modelId); + if (definition === undefined) { throw new Error2( ModelCatalogErrors.codes.MODEL_NOT_FOUND, `model ${modelId} does not exist`, ); } + const record = rawRecordOf(definition); const model = this.get(modelId); await this.models.setDefaultModel(modelId); return { @@ -221,9 +258,18 @@ export class ModelCatalog extends Disposable implements IModelCatalog { }; } + private allRecords(): Readonly> { + const out: Record = {}; + for (const alias of this.runtime.aliases()) { + const definition = this.runtime.lookup(alias); + if (definition !== undefined) out[alias] = rawRecordOf(definition); + } + return out; + } + private async toCatalogProvider( providerId: string, - provider: ProviderConfig, + provider: CatalogProviderInfo, models: Readonly>, globalDefaultModel: string | undefined, ): Promise { @@ -233,7 +279,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { private async resolveCredential( providerId: string, - provider: ProviderConfig, + provider: CatalogProviderInfo, ): Promise { return { hasApiKey: hasConfiguredApiKey(provider), @@ -241,7 +287,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { }; } - private async hasCachedToken(providerId: string, provider: ProviderConfig): Promise { + private async hasCachedToken(providerId: string, provider: CatalogProviderInfo): Promise { if (provider.oauth === undefined) return false; return this.oauth.hasCachedAccessToken(providerId, provider.oauth); } @@ -249,55 +295,37 @@ export class ModelCatalog extends Disposable implements IModelCatalog { private providerTypeOf(record: ModelRecord): string | undefined { const providerId = record.providerId ?? record.provider ?? this.providers.getDefaultProvider(); - return this.providers.get(providerId ?? '')?.type ?? record.protocol; + return this.runtime.providerInfo(providerId ?? '')?.type ?? record.protocol; } - private buildModel(id: string, trace: ResolutionTraceCollector): Model { - const configuredModel = this.models.get(id); - if (configuredModel === undefined) { + private buildModel(id: string): Model { + const definition = this.runtime.lookup(id); + if (definition === undefined) { throw new Error2( CONFIG_INVALID_ERROR_CODE, `Model "${id}" is not configured in config.toml.`, { details: { model: id } }, ); } - trace.capture(TRACE.configuredModel, configuredModel); - trace.record('model.record', { kind: 'config', detail: '[models.*] section' }); + const configuredModel = rawRecordOf(definition); - const routingModel = effectiveModelConfig(configuredModel); const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = - this.resolveProviderContext(id, routingModel, trace); - trace.capture(TRACE.providerConfig, providerConfig); - trace.capture(TRACE.providerName, providerName); - trace.capture(TRACE.rawBaseUrl, rawBaseUrl); - - const protocol = this.resolveProtocol(id, routingModel, providerConfig, trace); - const model = effectiveModelConfig( - configuredModel, + this.resolveProviderContext(id, configuredModel); + + const protocol = this.resolveProtocol(id, configuredModel, providerConfig); + const model = withAnthropicProfile( + effectiveRecordOf(definition), providerConfig?.type ?? configuredModel.protocol, ); - trace.capture(TRACE.effectiveModel, model); const wireName = model.name ?? model.model; - const profileAttribution = profileForAttribution(configuredModel, providerConfig, wireName); - attributeEffectiveFields( - trace, - configuredModel, - model, - profileAttribution.profile, - profileAttribution.inferred, - ); - const auth = resolveModelAuthMaterial( - { - modelId: id, - model, - provider: providerConfig, - providerName, - }, - trace, - ); - trace.capture(TRACE.authMaterial, auth); - const authProvider = this.buildAuthProvider(providerName, auth); + const auth = resolveModelAuthMaterial({ + modelId: id, + model, + provider: providerConfig, + providerName, + }); + const credentials = this.buildCredentials(providerName, auth); const providerType = providerConfig?.type ?? protocol; const resolvedBaseUrl = @@ -317,16 +345,14 @@ export class ModelCatalog extends Disposable implements IModelCatalog { ); } - const explainedCapability = this.protocolRegistry.explainCapability( + const detectedCapability = this.protocolRegistry.resolveCapability( protocol, wireName, providerType, ); - trace.capture(TRACE.detectedCapability, explainedCapability.capability); - trace.capture(TRACE.capabilitySource, explainedCapability.source); const capabilities = resolveModelCapabilities( model.capabilities, - explainedCapability.capability, + detectedCapability, model.maxContextSize, model.maxInputSize, ); @@ -336,14 +362,8 @@ export class ModelCatalog extends Disposable implements IModelCatalog { providerConfig, resolvedBaseUrl, ); - if (providerOptions !== undefined) { - attributeProviderOptions(trace, providerOptions, providerConfig?.env); - } const declared = new Set((model.capabilities ?? []).map((c) => c.trim().toLowerCase())); - trace.capture(TRACE.hostHeaders, this.hostRequestHeaders.headers); - trace.capture(TRACE.thirdPartyHeaders, this.hostRequestHeaders.thirdPartyHeaders); - trace.capture(TRACE.identitySlug, this.hostRequestHeaders.identitySlug); return { id, name: wireName, @@ -367,7 +387,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { alwaysThinking: declared.has('always_thinking'), providerType, providerName, - authProvider, + credentials, providerOptions, }; } @@ -375,37 +395,26 @@ export class ModelCatalog extends Disposable implements IModelCatalog { private resolveProviderContext( id: string, model: ModelRecord, - trace: ResolutionTraceCollector, ): { - readonly providerConfig: ProviderConfig | undefined; + readonly providerConfig: CatalogProviderInfo | undefined; readonly providerName: string; readonly resolvedBaseUrl: string | undefined; } { const providerId = model.providerId ?? model.provider ?? this.providers.getDefaultProvider(); if (providerId !== undefined) { - trace.record('provider', { - kind: 'config', - detail: - model.providerId !== undefined - ? `model.providerId '${providerId}'` - : model.provider !== undefined - ? `model.provider '${providerId}'` - : `[defaultProvider] '${providerId}'`, - }); - trace.capture(TRACE.providerSynthesized, false); - const providerConfig = this.providers.get(providerId); + const providerConfig = this.runtime.providerInfo(providerId); if (providerConfig === undefined) { throw new Error2( CONFIG_INVALID_ERROR_CODE, `Provider "${providerId}" referenced by model "${id}" is not configured.`, ); } - const endpoint = resolveEndpointBaseUrl(model, providerConfig, providerId); - if (endpoint.source !== undefined) { - trace.record('resolved.baseUrl', endpoint.source); - } - return { providerConfig, providerName: providerId, resolvedBaseUrl: endpoint.baseUrl }; + return { + providerConfig, + providerName: providerId, + resolvedBaseUrl: resolveEndpointBaseUrl(model, providerConfig), + }; } const modelBaseUrl = nonEmpty(model.baseUrl); @@ -415,16 +424,9 @@ export class ModelCatalog extends Disposable implements IModelCatalog { `Model "${id}" must set either providerId or baseUrl in config.toml.`, ); } - trace.record('provider', { - kind: 'synthesized', - detail: 'flat model — provider synthesized from the baseUrl host', - }); - trace.capture(TRACE.providerSynthesized, true); - trace.record('resolved.baseUrl', { kind: 'config', detail: 'model.baseUrl (flat)' }); - const originName = deriveProviderId(modelBaseUrl); return { providerConfig: undefined, - providerName: originName, + providerName: deriveProviderId(modelBaseUrl), resolvedBaseUrl: modelBaseUrl, }; } @@ -432,39 +434,34 @@ export class ModelCatalog extends Disposable implements IModelCatalog { private resolveProtocol( id: string, model: ModelRecord, - provider: ProviderConfig | undefined, - trace: ResolutionTraceCollector, + provider: CatalogProviderInfo | undefined, ): Protocol { - const resolution = resolveModelProtocol(model, provider); - if (resolution === undefined) { + const protocol = resolveModelProtocol(model, provider); + if (protocol === undefined) { throw new Error2( CONFIG_INVALID_ERROR_CODE, `Model "${id}" must declare a wire protocol (config: models..protocol).`, ); } - trace.record('resolved.protocol', resolution.source); - return resolution.protocol; + return protocol; } - private buildAuthProvider(providerName: string, auth: ResolvedModelAuthMaterial): AuthProvider { + private buildCredentials( + providerName: string, + auth: ResolvedModelAuthMaterial, + ): LlmCredentialProvider { if (auth.apiKey !== undefined) { - return new StaticAuthProvider(auth.apiKey); + return staticCredentials(auth.apiKey); } if (auth.oauth !== undefined) { const oauthRef = auth.oauth; const providerKey = auth.oauthProviderKey ?? providerName; const tokens = this.oauth; - return { - canRefresh: true, - async getAuth(options): Promise { - const apiKey = await tokens.getAccessToken(providerKey, oauthRef, { - force: options?.force === true, - }); - return { apiKey }; - }, - }; + return oauthCredentials((options) => + tokens.getAccessToken(providerKey, oauthRef, { force: options?.force === true }), + ); } - return new StaticAuthProvider(undefined); + return staticCredentials(undefined); } } @@ -505,10 +502,29 @@ function stripTrailingV1(baseUrl: string): string { return baseUrl.replace(/\/v1\/?$/, ''); } +function effectiveRecordOf(definition: CatalogModel): ModelRecord { + const raw = rawRecordOf(definition); + const { overrides, ...base } = raw; + return { + ...base, + capabilities: overrides?.capabilities ?? raw.capabilities, + maxContextSize: definition.maxContextSize, + maxInputSize: definition.maxInputSize, + maxOutputSize: definition.maxOutputSize, + displayName: definition.displayName, + reasoningKey: definition.reasoningKey, + adaptiveThinking: definition.adaptiveThinking, + supportEfforts: + definition.supportEfforts === undefined ? undefined : [...definition.supportEfforts], + defaultEffort: definition.defaultEffort, + offEffort: definition.offEffort, + }; +} + function buildProtocolProviderOptions( model: ModelRecord, protocol: Protocol, - provider: ProviderConfig | undefined, + provider: CatalogProviderInfo | undefined, baseUrl: string | undefined, ): ProtocolProviderOptions | undefined { const options: MutableProtocolProviderOptions = {}; @@ -550,38 +566,18 @@ function buildProtocolProviderOptions( : undefined; } -function profileForAttribution( - configuredModel: ModelRecord, - providerConfig: ProviderConfig | undefined, - wireName: string | undefined, -): { readonly profile: typeof LATEST_OPUS_PROFILE | undefined; readonly inferred: boolean } { - if (wireName === undefined) return { profile: undefined, inferred: false }; - const profileArg = providerConfig?.type ?? configuredModel.protocol; - const gateProtocol = configuredModel.protocol ?? profileArg; - const known = matchKnownAnthropicModelProfile(wireName); - const infer = - profileArg !== undefined && - !drivesThinkingThroughTraits(profileArg) && - gateProtocol === 'anthropic'; - if (infer) { - const fallback = known ?? matchUnknownClaudeProfile(wireName); - return { profile: fallback, inferred: known === undefined && fallback !== undefined }; - } - return { profile: known, inferred: false }; -} - -function vertexAIProject(provider: ProviderConfig | undefined): string | undefined { +function vertexAIProject(provider: CatalogProviderInfo | undefined): string | undefined { return envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT'); } function vertexAILocation( - provider: ProviderConfig | undefined, + provider: CatalogProviderInfo | undefined, baseUrl: string | undefined, ): string | undefined { return envValue(provider?.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl); } -function envValue(env: Record | undefined, key: string): string | undefined { +function envValue(env: Readonly> | undefined, key: string): string | undefined { return nonEmpty(env?.[key]); } @@ -597,7 +593,7 @@ function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | unde } } -function hasConfiguredApiKey(provider: ProviderConfig): boolean { +function hasConfiguredApiKey(provider: CatalogProviderInfo): boolean { if (nonEmpty(provider.apiKey) !== undefined) return true; if (provider.type === undefined) return false; return resolveProviderEndpoint(provider.type, provider.env ?? {}).apiKey !== undefined; diff --git a/packages/agent-core-v2/src/llm-adapter/model/catalog.ts b/packages/agent-core-v2/src/llm-adapter/model/catalog.ts index fa2c63a781b..c61f65a24bf 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/catalog.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/catalog.ts @@ -3,35 +3,19 @@ import { z } from 'zod'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ModelCapability } from '../contract/capability'; +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; import type { TokenUsage } from '#human/llm/usage'; import type { Protocol, ProtocolProviderOptions } from '../protocol/protocol'; import type { ProviderConfig } from '../provider/provider'; -import type { ModelInspection } from './inspection'; import type { ModelRecord } from './model'; import { effectiveModelConfig } from './model-auth'; -import type { ModelRequester } from './model-requester'; - -export interface ProviderRequestAuth { - apiKey?: string; - headers?: Record; -} - -export interface AuthProvider { - readonly canRefresh?: boolean; - - getAuth(options?: { readonly force?: boolean }): Promise; -} - -export class StaticAuthProvider implements AuthProvider { - readonly canRefresh = false; - - constructor(private readonly apiKey: string | undefined) {} - async getAuth(): Promise { - if (this.apiKey === undefined || this.apiKey.trim().length === 0) return undefined; - return { apiKey: this.apiKey }; - } -} +import type { + ModelRequestEvent, + ModelRequestInput, + ModelRequestParams, + ModelRequester, +} from './model-requester'; export interface Model { readonly id: string; @@ -54,7 +38,7 @@ export interface Model { readonly providerType?: string; readonly providerName: string; - readonly authProvider: AuthProvider; + readonly credentials?: LlmCredentialProvider; readonly providerOptions?: ProtocolProviderOptions; } @@ -185,7 +169,12 @@ export interface IModelCatalog { get(id: string): Model; getRequester(id: string): ModelRequester; - inspect(id: string): ModelInspection; + generate( + id: string, + input: ModelRequestInput, + signal?: AbortSignal, + params?: ModelRequestParams, + ): AsyncIterable; ping(id: string): Promise; findByName(name: string): readonly string[]; diff --git a/packages/agent-core-v2/src/llm-adapter/model/credential-recovery.ts b/packages/agent-core-v2/src/llm-adapter/model/credential-recovery.ts new file mode 100644 index 00000000000..19424961f3c --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/model/credential-recovery.ts @@ -0,0 +1,35 @@ +import type { LlmCredentialProvider } from '#human/llm/requester/requester'; + +export async function runWithCredentialRecovery( + credentials: LlmCredentialProvider | undefined, + run: () => Promise, + signal?: AbortSignal, +): Promise { + try { + return await run(); + } catch (error) { + if (signal?.aborted === true || credentials?.canRecover?.(error) !== true) throw error; + credentials?.invalidate?.(); + return run(); + } +} + +export async function* streamWithCredentialRecovery( + credentials: LlmCredentialProvider | undefined, + stream: () => AsyncIterable, + signal?: AbortSignal, +): AsyncIterable { + let recovered = false; + for (;;) { + try { + yield* stream(); + return; + } catch (error) { + if (recovered || signal?.aborted === true || credentials?.canRecover?.(error) !== true) { + throw error; + } + recovered = true; + credentials?.invalidate?.(); + } + } +} diff --git a/packages/agent-core-v2/src/llm-adapter/model/inspection.ts b/packages/agent-core-v2/src/llm-adapter/model/inspection.ts deleted file mode 100644 index 964d2cf3a59..00000000000 --- a/packages/agent-core-v2/src/llm-adapter/model/inspection.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; - -import { BugIndicatingError } from '#/_base/errors/errors'; -import type { AnthropicModelProfile } from '#human/llm/requester/bases/anthropic/profile'; - -import type { ModelCapability } from '../contract/capability'; -import type { InspectionSource, ResolutionTrace } from '../contract/inspection'; -import type { Protocol, ProtocolProviderOptions } from '../protocol/protocol'; -import type { ProviderConfig } from '../provider/provider'; -import { getProviderDefinition } from '../provider/provider-definition'; - -import type { ModelRecord } from './model'; -import type { ResolvedModelAuthMaterial } from './model.types'; - -export interface InspectedAuth { - readonly kind: 'apiKey' | 'oauth' | 'none'; - readonly apiKey?: string; - readonly oauthProviderKey?: string; -} - -export interface InspectedResolvedModel { - readonly protocol: Protocol; - readonly providerType?: string; - readonly providerName: string; - readonly baseUrl?: string; - readonly wireName: string; - readonly aliases: readonly string[]; - readonly auth: InspectedAuth; - readonly capabilities: ModelCapability; - readonly maxContextSize: number; - readonly maxInputSize?: number; - readonly maxOutputSize?: number; - readonly displayName?: string; - readonly reasoningKey?: string; - readonly supportEfforts?: readonly string[]; - readonly defaultEffort?: string; - readonly alwaysThinking: boolean; - readonly headers: Readonly>; - readonly providerOptions?: ProtocolProviderOptions; -} - -export interface ModelInspection { - readonly id: string; - readonly model: { - readonly id: string; - readonly record: ModelRecord; - readonly effective: ModelRecord; - }; - readonly provider: { - readonly id: string; - readonly synthesized: boolean; - readonly config?: ProviderConfig; - readonly definition?: { - readonly registered: boolean; - readonly baseProtocol?: Protocol; - readonly modelSource?: string; - readonly hostHeaders?: string; - readonly endpoint?: unknown; - }; - }; - readonly resolved: InspectedResolvedModel; - readonly sources: Readonly>; -} - -export const TRACE = { - configuredModel: 'configuredModel', - effectiveModel: 'effectiveModel', - providerConfig: 'providerConfig', - providerName: 'providerName', - providerSynthesized: 'providerSynthesized', - rawBaseUrl: 'rawBaseUrl', - authMaterial: 'authMaterial', - detectedCapability: 'detectedCapability', - capabilitySource: 'capabilitySource', - hostHeaders: 'hostHeaders', - thirdPartyHeaders: 'thirdPartyHeaders', - identitySlug: 'identitySlug', -} as const; - -export class ResolutionTraceCollector implements ResolutionTrace { - private readonly sourceMap = new Map(); - private readonly captureMap = new Map(); - - record(path: string, source: InspectionSource): void { - this.sourceMap.set(path, source); - } - - capture(key: string, value: unknown): void { - this.captureMap.set(key, value); - } - - captured(key: string): T | undefined { - return this.captureMap.get(key) as T | undefined; - } - - get sources(): ReadonlyMap { - return this.sourceMap; - } -} - -const SECRET_KEY_RE = /api[-_]?key|token|secret|password|authorization/i; - -export function maskSecret(value: string): string { - if (value.length <= 4) return '••••'; - return `••••${value.slice(-4)}`; -} - -export function redactSecrets(value: T): T { - if (Array.isArray(value)) return value.map((item) => redactSecrets(item)) as T; - if (value !== null && typeof value === 'object') { - const out: Record = {}; - for (const [key, item] of Object.entries(value)) { - out[key] = typeof item === 'string' && SECRET_KEY_RE.test(key) ? maskSecret(item) : redactSecrets(item); - } - return out as T; - } - return value; -} - -export function attributeEffectiveFields( - trace: ResolutionTraceCollector, - configured: ModelRecord, - effective: ModelRecord, - profile: AnthropicModelProfile | undefined, - profileInferred: boolean, -): void { - const { overrides, ...base } = configured; - const overridden = new Set(Object.keys(overrides ?? {})); - const profileDetail = - profile === undefined - ? undefined - : `anthropic profile (${profile.mode}, efforts: ${profile.efforts.join('/')}${profileInferred ? ', inferred fallback' : ''})`; - const keys = new Set([...Object.keys(base), ...Object.keys(effective)]); - for (const key of keys) { - if (key === 'overrides') continue; - const path = `model.effective.${key}`; - const before = (base as Record)[key]; - const after = (effective as Record)[key]; - if (before === undefined && after === undefined) continue; - if (key === 'maxInputSize') { - const rawValue = (overridden.has(key) ? overrides?.[key] : before) as number | undefined; - if ( - rawValue !== undefined && - effective.maxContextSize !== undefined && - rawValue > effective.maxContextSize - ) { - trace.record(path, { - kind: 'synthesized', - detail: 'clamped to the effective max_context_size', - }); - continue; - } - } - if (overridden.has(key)) { - trace.record(path, { kind: 'override', detail: 'models.*.overrides' }); - continue; - } - if (after === undefined) { - trace.record(path, { - kind: 'synthesized', - detail: 'removed by the effective pass (defaultEffort not in override supportEfforts)', - }); - continue; - } - const profileTouched = - (key === 'capabilities' || key === 'supportEfforts' || key === 'defaultEffort') && - profileDetail !== undefined && - JSON.stringify(before) !== JSON.stringify(after); - if (profileTouched) { - trace.record(path, { kind: 'builtin', detail: profileDetail }); - continue; - } - trace.record(path, { kind: 'config', detail: '[models.*] section' }); - } -} - -const PROVIDER_OPTION_FIELD: Readonly> = { - defaultMaxTokens: 'maxOutputSize', - supportEfforts: 'supportEfforts', - adaptiveThinking: 'adaptiveThinking', - betaApi: 'betaApi', - reasoningKey: 'reasoningKey', -}; - -export function attributeProviderOptions( - trace: ResolutionTraceCollector, - options: ProtocolProviderOptions, - providerEnv: Readonly> | undefined, -): void { - for (const key of Object.keys(options)) { - const path = `resolved.providerOptions.${key}`; - if (key === 'vertexai') { - trace.record(path, { kind: 'env', detail: 'provider env bag supplies both vertex coordinates' }); - continue; - } - if (key === 'project') { - trace.record(path, { kind: 'env', detail: 'GOOGLE_CLOUD_PROJECT (provider env bag)' }); - continue; - } - if (key === 'location') { - trace.record( - path, - providerEnv?.['GOOGLE_CLOUD_LOCATION'] !== undefined - ? { kind: 'env', detail: 'GOOGLE_CLOUD_LOCATION (provider env bag)' } - : { kind: 'synthesized', detail: 'parsed from the baseUrl host' }, - ); - continue; - } - const field = PROVIDER_OPTION_FIELD[key]; - const source = field === undefined ? undefined : trace.sources.get(`model.effective.${field}`); - trace.record(path, source ?? { kind: 'config', detail: '[models.*] section' }); - } -} - -interface ResolvedModelLike { - readonly protocol: Protocol; - readonly providerType?: string; - readonly providerName: string; - readonly baseUrl?: string; - readonly name: string; - readonly aliases: readonly string[]; - readonly capabilities: ModelCapability; - readonly maxContextSize: number; - readonly maxInputSize?: number; - readonly maxOutputSize?: number; - readonly displayName?: string; - readonly reasoningKey?: string; - readonly supportEfforts?: readonly string[]; - readonly defaultEffort?: string; - readonly alwaysThinking: boolean; - readonly headers: Readonly>; - readonly providerOptions?: ProtocolProviderOptions; -} - -const CAPABILITY_KEYS = [ - 'image_in', - 'video_in', - 'audio_in', - 'thinking', - 'tool_use', - 'dynamically_loaded_tools', -] as const; - -export function assembleModelInspection(args: { - readonly id: string; - readonly model: ResolvedModelLike; - readonly trace: ResolutionTraceCollector; -}): ModelInspection { - const { id, model, trace } = args; - const configured = required(trace, TRACE.configuredModel, 'configured model'); - const effective = required(trace, TRACE.effectiveModel, 'effective model'); - const providerConfig = trace.captured(TRACE.providerConfig); - const providerName = trace.captured(TRACE.providerName) ?? model.providerName; - const providerSynthesized = trace.captured(TRACE.providerSynthesized) === true; - const rawBaseUrl = trace.captured(TRACE.rawBaseUrl); - const authMaterial = trace.captured(TRACE.authMaterial) ?? {}; - - const sources = new Map([ - ...trace.sources, - [ - 'model.effective', - { - kind: 'synthesized', - detail: 'overrides merged into the raw record, then the Anthropic profile pass fills gaps', - } satisfies InspectionSource, - ], - [ - 'resolved', - { - kind: 'synthesized', - detail: 'the assembled runtime view (Model) of this same resolution pass', - } satisfies InspectionSource, - ], - ]); - - for (const field of [ - 'maxContextSize', - 'maxInputSize', - 'maxOutputSize', - 'displayName', - 'reasoningKey', - 'supportEfforts', - 'defaultEffort', - 'aliases', - ] as const) { - const source = sources.get(`model.effective.${field}`); - if (source !== undefined) sources.set(`resolved.${field}`, source); - } - const wireNameField = effective.name !== undefined ? 'name' : 'model'; - sources.set( - 'resolved.wireName', - sources.get(`model.effective.${wireNameField}`) ?? { kind: 'config', detail: '[models.*] section' }, - ); - sources.set('resolved.alwaysThinking', { - kind: 'synthesized', - detail: "derived from the declared capabilities ('always_thinking' present)", - }); - sources.set( - 'resolved.providerType', - providerConfig !== undefined - ? { kind: 'config', detail: `provider '${providerName}' type` } - : { kind: 'synthesized', detail: 'no provider — falls back to the resolved protocol' }, - ); - sources.set( - 'resolved.providerName', - sources.get('provider') ?? { kind: 'config', detail: `provider '${providerName}'` }, - ); - - sources.set('model', { kind: 'config', detail: 'the [models.*] section entry' }); - sources.set('model.id', { kind: 'config', detail: 'the [models.*] section key' }); - sources.set('resolved.headers', { - kind: 'synthesized', - detail: 'env < host < provider customHeaders merge (later wins)', - }); - - const baseUrlSource = sources.get('resolved.baseUrl'); - if ( - baseUrlSource !== undefined && - model.protocol === 'anthropic' && - rawBaseUrl !== undefined && - rawBaseUrl !== model.baseUrl - ) { - sources.set('resolved.baseUrl', { - kind: 'synthesized', - detail: `${baseUrlSource.detail ?? baseUrlSource.kind} · trailing /v1 stripped`, - }); - } - - attributeCapabilities(sources, configured, effective, trace); - attributeHeaders(sources, model, providerConfig, trace); - - const providerType = providerConfig?.type; - const definition = providerType === undefined ? undefined : getProviderDefinition(providerType); - if (providerConfig !== undefined) { - sources.set('provider.config', { kind: 'config', detail: '[providers.*] section' }); - sources.set('provider.definition', { - kind: 'builtin', - detail: - definition === undefined - ? `vendor '${providerType}' is not registered in the provider-definition registry` - : `provider definition '${providerType}'`, - }); - } - - const auth: InspectedAuth = - authMaterial.apiKey !== undefined - ? { kind: 'apiKey', apiKey: maskSecret(authMaterial.apiKey) } - : authMaterial.oauth !== undefined - ? { kind: 'oauth', oauthProviderKey: authMaterial.oauthProviderKey } - : { kind: 'none' }; - - return { - id, - model: { - id, - record: redactSecrets(configured), - effective: redactSecrets(effective), - }, - provider: { - id: providerName, - synthesized: providerSynthesized, - config: providerConfig === undefined ? undefined : redactSecrets(providerConfig), - definition: - providerConfig === undefined - ? undefined - : { - registered: definition !== undefined, - ...(definition === undefined - ? undefined - : { - baseProtocol: definition.baseProtocol, - modelSource: definition.modelSource, - hostHeaders: definition.hostHeaders, - endpoint: definition.endpoint, - }), - }, - }, - resolved: { - protocol: model.protocol, - providerType: model.providerType, - providerName: model.providerName, - baseUrl: model.baseUrl, - wireName: model.name, - aliases: model.aliases, - auth, - capabilities: model.capabilities, - maxContextSize: model.maxContextSize, - maxInputSize: model.maxInputSize, - maxOutputSize: model.maxOutputSize, - displayName: model.displayName, - reasoningKey: model.reasoningKey, - supportEfforts: model.supportEfforts, - defaultEffort: model.defaultEffort, - alwaysThinking: model.alwaysThinking, - headers: model.headers, - providerOptions: model.providerOptions, - }, - sources: Object.fromEntries(sources), - }; -} - -function attributeCapabilities( - sources: Map, - configured: ModelRecord, - effective: ModelRecord, - trace: ResolutionTraceCollector, -): void { - const raw = new Set((configured.capabilities ?? []).map((c) => c.trim().toLowerCase())); - const added = new Set((effective.capabilities ?? []).map((c) => c.trim().toLowerCase())); - const detected = trace.captured(TRACE.detectedCapability); - const detectedSource = trace.captured(TRACE.capabilitySource) ?? { - kind: 'none' as const, - }; - const profileSource = sources.get('model.effective.capabilities'); - for (const key of CAPABILITY_KEYS) { - const path = `resolved.capabilities.${key}`; - if (raw.has(key) || (key === 'thinking' && raw.has('always_thinking'))) { - sources.set(path, { kind: 'config', detail: 'declared in model capabilities' }); - continue; - } - if (added.has(key) || (key === 'thinking' && added.has('always_thinking'))) { - sources.set( - path, - profileSource ?? { kind: 'builtin', detail: 'added by the Anthropic profile pass' }, - ); - continue; - } - if (detected?.[key] === true) { - sources.set(path, detectedSource); - continue; - } - sources.set(path, { kind: 'none', detail: 'neither declared nor detected' }); - } - sources.set('resolved.capabilities.max_context_tokens', { - kind: 'synthesized', - detail: 'forced to the resolved maxContextSize', - }); - const maxInputSource = sources.get('model.effective.maxInputSize'); - sources.set( - 'resolved.capabilities.max_input_tokens', - maxInputSource ?? { - kind: 'none', - detail: 'no declared input limit — the total window applies', - }, - ); -} - -function hostHeaderDetail( - forwardsAll: boolean, - key: string, - identitySlug: string | undefined, -): string { - if (forwardsAll) return "host request headers (hostHeaders: 'full')"; - return identitySlug !== undefined && key === 'User-Agent' - ? `host User-Agent, product token from [identity] (${identitySlug})` - : 'host User-Agent'; -} - -function attributeHeaders( - sources: Map, - model: ResolvedModelLike, - providerConfig: ProviderConfig | undefined, - trace: ResolutionTraceCollector, -): void { - const envLayer = parseKimiCodeCustomHeaders(); - const rawHost = trace.captured>>(TRACE.hostHeaders) ?? {}; - const identitySlug = trace.captured(TRACE.identitySlug); - const forwardsAll = - providerConfig?.type !== undefined && - getProviderDefinition(providerConfig.type)?.hostHeaders === 'full'; - const hostLayer: Readonly> = forwardsAll - ? rawHost - : trace.captured>>(TRACE.thirdPartyHeaders) ?? {}; - const customLayer = providerConfig?.customHeaders ?? {}; - for (const key of Object.keys(model.headers)) { - const path = `resolved.headers.${key}`; - if (key in customLayer) { - sources.set(path, { kind: 'config', detail: "provider's customHeaders" }); - } else if (key in hostLayer) { - sources.set(path, { - kind: 'builtin', - detail: hostHeaderDetail(forwardsAll, key, identitySlug), - }); - } else if (key in envLayer) { - sources.set(path, { kind: 'env', detail: 'KIMI_CODE_CUSTOM_HEADERS' }); - } - } -} - -function required(trace: ResolutionTraceCollector, key: string, what: string): T { - const value = trace.captured(key); - if (value === undefined) { - throw new BugIndicatingError(`resolution trace is missing the ${what} capture ('${key}')`); - } - return value; -} diff --git a/packages/agent-core-v2/src/llm-adapter/model/model-auth.ts b/packages/agent-core-v2/src/llm-adapter/model/model-auth.ts index 625ba75feb0..55fdfeefd6c 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/model-auth.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-auth.ts @@ -6,7 +6,6 @@ import { } from '#human/llm/requester/bases/anthropic/profile'; import { CONFIG_INVALID_ERROR_CODE } from '../contract/errors'; -import type { InspectionSource, ResolutionTrace } from '../contract/inspection'; import { ProtocolSchema, type Protocol } from '../protocol/protocol'; import type { ProviderConfig } from '../provider/provider'; import { explainProviderEndpoint, getProviderDefinition } from '../provider/provider-definition'; @@ -24,25 +23,20 @@ function getActiveProviderApiKey(provider: ProviderConfig | undefined): string | return provider.apiKey; } -export function resolveModelAuthMaterial( - args: { - readonly modelId: string; - readonly model: ModelRecord; - readonly provider: ProviderConfig | undefined; - readonly providerName: string; - }, - trace?: ResolutionTrace, -): ResolvedModelAuthMaterial { +export function resolveModelAuthMaterial(args: { + readonly modelId: string; + readonly model: ModelRecord; + readonly provider: ProviderConfig | undefined; + readonly providerName: string; +}): ResolvedModelAuthMaterial { const modelApiKey = nonEmpty(args.model.apiKey); if (modelApiKey !== undefined && args.model.oauth !== undefined) { throw authConflictError('Model', args.modelId); } if (modelApiKey !== undefined) { - trace?.record('resolved.auth', { kind: 'config', detail: 'model.apiKey' }); return { apiKey: modelApiKey }; } if (args.model.oauth !== undefined) { - trace?.record('resolved.auth', { kind: 'config', detail: 'model.oauth' }); return { oauth: args.model.oauth, oauthProviderKey: args.model.providerId ?? args.model.provider, @@ -59,31 +53,14 @@ export function resolveModelAuthMaterial( throw authConflictError('Provider', args.providerName); } if (providerApiKey !== undefined) { - trace?.record( - 'resolved.auth', - getActiveProviderApiKey(args.provider) !== undefined - ? { kind: 'config', detail: `provider '${args.providerName}' apiKey (active: ${args.provider?.activeApiKeyId ?? 'legacy'})` } - : { - kind: 'env', - detail: `${providerEndpoint.apiKeyEnvName ?? '?'} (provider '${args.providerName}' env bag)`, - }, - ); return { apiKey: providerApiKey }; } if (args.provider?.oauth !== undefined) { - trace?.record('resolved.auth', { - kind: 'config', - detail: `provider '${args.providerName}' oauth`, - }); return { oauth: args.provider.oauth, oauthProviderKey: args.model.providerId ?? args.model.provider, }; } - trace?.record('resolved.auth', { - kind: 'none', - detail: 'no credential resolved at any layer (adapter construction may still read process.env)', - }); return {}; } @@ -110,7 +87,7 @@ export function effectiveModelConfig( return withAnthropicProfile(clamped, providerType); } -function withAnthropicProfile(model: ModelRecord, providerType?: string): ModelRecord { +export function withAnthropicProfile(model: ModelRecord, providerType?: string): ModelRecord { const wireName = model.name ?? model.model; const protocol = model.protocol ?? providerType; const profile = @@ -151,82 +128,43 @@ export function providerNameFromFlatModel(model: ModelRecord): string | undefine return baseUrl === undefined ? undefined : deriveProviderId(baseUrl); } -export interface ModelProtocolResolution { - readonly protocol: Protocol; - readonly source: InspectionSource; -} - export function resolveModelProtocol( model: ModelRecord, provider: ProviderConfig | undefined, -): ModelProtocolResolution | undefined { +): Protocol | undefined { if (model.protocol !== undefined) { - return { protocol: model.protocol, source: { kind: 'config', detail: 'model.protocol' } }; + return model.protocol; } const providerType = provider?.type; if (providerType !== undefined) { const asProtocol = ProtocolSchema.safeParse(providerType); if (asProtocol.success) { - return { - protocol: asProtocol.data, - source: { - kind: 'config', - detail: `provider type '${providerType}' is itself a wire protocol`, - }, - }; + return asProtocol.data; } const definition = getProviderDefinition(providerType); if (definition !== undefined) { - return { - protocol: definition.baseProtocol, - source: { kind: 'builtin', detail: `vendor '${providerType}' declared baseProtocol` }, - }; + return definition.baseProtocol; } } return undefined; } -export interface EndpointBaseUrlResolution { - readonly baseUrl: string | undefined; - readonly source?: InspectionSource; -} - export function resolveEndpointBaseUrl( model: ModelRecord, provider: ProviderConfig, - providerId: string, -): EndpointBaseUrlResolution { +): string | undefined { const fromModel = nonEmpty(model.baseUrl); if (fromModel !== undefined) { - return { baseUrl: fromModel, source: { kind: 'config', detail: 'model.baseUrl' } }; + return fromModel; } const fromProvider = nonEmpty(provider.baseUrl); if (fromProvider !== undefined) { - return { - baseUrl: fromProvider, - source: { kind: 'config', detail: `provider '${providerId}' baseUrl` }, - }; + return fromProvider; } const endpointType = provider.type ?? model.protocol; const endpoint = endpointType === undefined ? {} : explainProviderEndpoint(endpointType, provider.env ?? {}); - const baseUrl = nonEmpty(endpoint.baseUrl); - if (endpoint.baseUrlEnvName !== undefined) { - return { - baseUrl, - source: { - kind: 'env', - detail: `${endpoint.baseUrlEnvName} (provider '${providerId}' env bag)`, - }, - }; - } - if (endpoint.baseUrlIsDefault === true) { - return { - baseUrl, - source: { kind: 'builtin', detail: `provider definition '${endpointType}' defaultBaseUrl` }, - }; - } - return { baseUrl }; + return nonEmpty(endpoint.baseUrl); } export type ModelReadyFailureReason = diff --git a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts index d347a8e9cbd..2cbb94a2405 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts @@ -10,8 +10,8 @@ import type { ProviderMediaContribution, VideoUploadInput } from '#human/llm/med import { createMessageAccumulator, type VideoURLPart } from '#human/llm/message'; import type { LlmModel } from '#human/llm/model'; import type { ProtocolName } from '#human/llm/protocol/base'; +import { applyCredential, resolveModelCredentials } from '#human/credentials/credentials'; import { - mergeRequestHeaders, type ExtraParams, type LlmRequestConfig, type LlmRequestContent, @@ -19,17 +19,11 @@ import { type LlmRequester, } from '#human/llm/requester/requester'; import type { TokenUsage } from '#human/llm/usage'; -import { - withAuth, - withAuthUpload, - type CredentialSource, -} from '#human/kimi-oauth/credential-source'; import { ChatProviderError, errorFromLlmMessage, isAbortError, - isUnauthorizedLlmError, llmMessageFromError, traceIdFromHeadersRecord, VideoUploadUnsupportedError, @@ -37,7 +31,7 @@ import { import { fromLlmAssistantMessage, toLlmMessage, type Tool } from '../contract/message'; import { mergeUsagePatch } from '#human/llm/usage'; -import type { Model, ProviderRequestAuth } from './catalog'; +import type { Model } from './catalog'; import type { ModelRequestEvent, ModelRequestInput, @@ -83,20 +77,11 @@ export class ModelRequesterImpl implements ModelRequester { private requesterFor(resolved: ResolvedLlmModel): LlmRequester { if (this.cachedRequester === undefined) { - this.cachedRequester = withAuth(throwToEvent(resolved.requester), this.credentialSource); + this.cachedRequester = throwToEvent(resolved.requester); } return this.cachedRequester; } - private readonly credentialSource: CredentialSource = { - resolve: async (model, options) => { - const auth = await this.model.authProvider.getAuth({ force: options?.force }); - return applyAuth(model, auth); - }, - canRecover: (_model, error) => - this.model.authProvider.canRefresh === true && isUnauthorizedLlmError(error), - }; - request( input: ModelRequestInput, signal?: AbortSignal, @@ -122,8 +107,8 @@ export class ModelRequesterImpl implements ModelRequester { ); } const video = typeof input === 'string' ? readVideoFile(input) : input; - const wrapped = withAuthUpload(uploader, this.credentialSource); - return wrapped(video, { model: resolved.model, signal: options?.signal }); + const model = await resolveModelCredentials(resolved.model, this.model.credentials); + return uploader(video, { model, signal: options?.signal }); } private async runRequest( @@ -172,71 +157,76 @@ export class ModelRequesterImpl implements ModelRequester { usedContextTokens: params?.usedContextTokens, }; - await requester.generate(config, content, { - signal: signal ?? new AbortController().signal, - onEvent: (event: LlmRequestEvent) => { - switch (event.type) { - case 'llm.sent': { - const now = Date.now(); - if (requestSentAt !== undefined) { - requestStartedAt = now; - accumulator = createMessageAccumulator(); - usage = undefined; - finish = undefined; - messageId = undefined; + const credential = await this.model.credentials?.resolve(); + await requester.generate( + { ...config, model: applyCredential(resolved.model, credential) }, + content, + { + signal: signal ?? new AbortController().signal, + onEvent: (event: LlmRequestEvent) => { + switch (event.type) { + case 'llm.sent': { + const now = Date.now(); + if (requestSentAt !== undefined) { + requestStartedAt = now; + accumulator = createMessageAccumulator(); + usage = undefined; + finish = undefined; + messageId = undefined; + } + requestSentAt = now; + return; } - requestSentAt = now; - return; - } - case 'llm.streaming.headers': { - traceId = traceIdFromHeadersRecord(event.headers); - params?.onTraceId?.(traceId); - return; - } - case 'llm.streaming.part': { - const arrivedAt = Date.now(); - if (firstChunkAt === undefined) { - firstChunkAt = arrivedAt; - decodeEluStart = performance.eventLoopUtilization(); - } else { - serverDecodeMs += arrivedAt - lastResumeAt; + case 'llm.streaming.headers': { + traceId = traceIdFromHeadersRecord(event.headers); + params?.onTraceId?.(traceId); + return; } - accumulator.push(event.part); - queue.push({ type: 'part', part: event.part }); - lastResumeAt = Date.now(); - clientConsumeMs += lastResumeAt - arrivedAt; - return; - } - case 'llm.streaming.usage': { - usage = mergeUsagePatch(usage, event.usage); - return; - } - case 'llm.streaming.finish': { - finish = event.finish; - return; - } - case 'llm.streaming.message_id': { - messageId = event.messageId; - return; - } - case 'llm.failed.syntax': - case 'llm.failed.remote': { - failed = event.error; - return; - } - case 'llm.done': { - streamEndedAt = Date.now(); - if (firstChunkAt !== undefined) { - serverDecodeMs += streamEndedAt - lastResumeAt; - if (decodeEluStart !== undefined) { - decodeEluEnd = performance.eventLoopUtilization(decodeEluStart); + case 'llm.streaming.part': { + const arrivedAt = Date.now(); + if (firstChunkAt === undefined) { + firstChunkAt = arrivedAt; + decodeEluStart = performance.eventLoopUtilization(); + } else { + serverDecodeMs += arrivedAt - lastResumeAt; } + accumulator.push(event.part); + queue.push({ type: 'part', part: event.part }); + lastResumeAt = Date.now(); + clientConsumeMs += lastResumeAt - arrivedAt; + return; + } + case 'llm.streaming.usage': { + usage = mergeUsagePatch(usage, event.usage); + return; + } + case 'llm.streaming.finish': { + finish = event.finish; + return; + } + case 'llm.streaming.message_id': { + messageId = event.messageId; + return; + } + case 'llm.failed.syntax': + case 'llm.failed.remote': { + failed = event.error; + return; + } + case 'llm.done': { + streamEndedAt = Date.now(); + if (firstChunkAt !== undefined) { + serverDecodeMs += streamEndedAt - lastResumeAt; + if (decodeEluStart !== undefined) { + decodeEluEnd = performance.eventLoopUtilization(decodeEluStart); + } + } + return; } - return; } - } + }, }, - }); + ); if (failed !== undefined) { throw errorFromLlmMessage(failed); @@ -293,15 +283,6 @@ function finalizeDecodeStats( }; } -function applyAuth(model: LlmModel, auth: ProviderRequestAuth | undefined): LlmModel { - if (auth === undefined) return model; - return { - ...model, - apiKey: auth.apiKey ?? model.apiKey, - defaultHeaders: mergeRequestHeaders(model.defaultHeaders, auth.headers), - }; -} - function throwToEvent(inner: LlmRequester): LlmRequester { return { async generate(config, content, control) { diff --git a/packages/agent-core-v2/src/llm-adapter/model/thinking.ts b/packages/agent-core-v2/src/llm-adapter/model/thinking.ts index c8531c0ef54..c3bcb2f138e 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/thinking.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/thinking.ts @@ -16,8 +16,8 @@ export { resolveThinkingKeep } from '#human/llm/thinking'; export function drivesThinkingThroughTraits(providerType: string | undefined): boolean { if (providerType === undefined) return false; - return getProviderDefinitions(providerType).some((definition) => - definition.traits.some((trait) => trait.withThinking !== undefined), + return getProviderDefinitions(providerType).some( + (definition) => definition.trait?.thinking !== undefined, ); } @@ -26,9 +26,9 @@ export function usesTraitDrivenThinking( protocol: Protocol, providerType?: string, ): boolean { - return registry - .resolveAdapterIdentity(protocol, providerType) - .traits.some(({ trait }) => trait.withThinking !== undefined); + return ( + registry.resolveAdapterIdentity(protocol, providerType).trait?.thinking !== undefined + ); } export function requiresStrictThinkingValidation( @@ -37,14 +37,9 @@ export function requiresStrictThinkingValidation( providerType?: string, ): boolean { if (providerType === undefined) return false; - const traits = registry.resolveAdapterIdentity(protocol, providerType).traits; - let strict = false; - for (const { trait } of traits) { - if (trait.withThinking !== undefined) { - strict = trait.strictThinkingValidation === true; - } - } - return strict; + const trait = registry.resolveAdapterIdentity(protocol, providerType).trait; + if (trait === undefined || trait.thinking === undefined) return false; + return 'strictThinkingValidation' in trait && trait.strictThinkingValidation === true; } export function wireHasProtocolThinkingDisable(protocol: string | undefined): boolean { diff --git a/packages/agent-core-v2/src/llm-adapter/protocol/protocol-base.ts b/packages/agent-core-v2/src/llm-adapter/protocol/protocol-base.ts index 99fd1601d21..5d7b5ecc010 100644 --- a/packages/agent-core-v2/src/llm-adapter/protocol/protocol-base.ts +++ b/packages/agent-core-v2/src/llm-adapter/protocol/protocol-base.ts @@ -1,32 +1,22 @@ import type { ProtocolBase } from '#human/llm/protocol/base'; -import type { ProtocolTrait } from '#human/llm/protocol/trait'; +import type { AnyProtocolTrait } from '#human/llm/provider/definition'; import { anthropicBase } from '#human/llm/requester/bases/anthropic/requester'; import { googleGenAIBase } from '#human/llm/requester/bases/google-genai/requester'; import { openAIBase } from '#human/llm/requester/bases/openai/requester'; import { openAIResponsesBase } from '#human/llm/requester/bases/openai-responses/requester'; -import type { Protocol, ProtocolAdapterConfig } from './protocol'; +import type { Protocol } from './protocol'; export type ProtocolBaseId = Protocol; export interface ProtocolBaseDefinition { readonly id: ProtocolBaseId; - readonly base: ProtocolBase; -} - -export interface TraitContext { - readonly config: ProtocolAdapterConfig; - readonly providerId?: string; -} - -export interface ResolvedTrait { - readonly trait: ProtocolTrait; - readonly context: TraitContext; + readonly base: ProtocolBase; } export interface ResolvedAdapterIdentity { readonly baseId: ProtocolBaseId; - readonly traits: readonly ResolvedTrait[]; + readonly trait?: AnyProtocolTrait; } const PROTOCOL_BASES: readonly ProtocolBaseDefinition[] = [ diff --git a/packages/agent-core-v2/src/llm-adapter/protocol/protocol.ts b/packages/agent-core-v2/src/llm-adapter/protocol/protocol.ts index 0ed841d47ec..16fee862a31 100644 --- a/packages/agent-core-v2/src/llm-adapter/protocol/protocol.ts +++ b/packages/agent-core-v2/src/llm-adapter/protocol/protocol.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ModelCapability } from '../contract/capability'; -import type { InspectionSource } from '../contract/inspection'; import type { Model } from '../model/catalog'; import type { ResolvedLlmModel } from '../model/model-requester-impl'; @@ -38,11 +37,6 @@ export interface ProtocolAdapterConfig { readonly providerOptions?: ProtocolProviderOptions; } -export interface ExplainedCapability { - readonly capability: ModelCapability; - readonly source: InspectionSource; -} - export interface IProtocolAdapterRegistry { readonly _serviceBrand: undefined; @@ -58,12 +52,6 @@ export interface IProtocolAdapterRegistry { providerType?: string, ): ModelCapability; - explainCapability( - protocol: Protocol, - modelName: string, - providerType?: string, - ): ExplainedCapability; - resolve(model: Model): ResolvedLlmModel; } diff --git a/packages/agent-core-v2/src/llm-adapter/protocol/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/llm-adapter/protocol/protocolAdapterRegistry.ts index adbc37deafd..26855538936 100644 --- a/packages/agent-core-v2/src/llm-adapter/protocol/protocolAdapterRegistry.ts +++ b/packages/agent-core-v2/src/llm-adapter/protocol/protocolAdapterRegistry.ts @@ -6,12 +6,15 @@ import type { ModelThinkingMetadata } from '#human/llm/thinking'; import type { ProviderMediaContribution } from '#human/llm/media/upload'; import type { LlmModel } from '#human/llm/model'; import type { ProtocolBase } from '#human/llm/protocol/base'; -import type { ProtocolTrait } from '#human/llm/protocol/trait'; +import type { ProviderConnection } from '#human/llm/protocol/connection'; +import type { AnyProtocolTrait } from '#human/llm/provider/definition'; +import type { LlmErrorClassifier } from '#human/llm/requester/requester'; import { anthropicBase, anthropicBetaBase } from '#human/llm/requester/bases/anthropic/requester'; import { createGoogleGenAIBase, googleGenAIBase, } from '#human/llm/requester/bases/google-genai/requester'; +import type { OpenAITrait } from '#human/llm/requester/bases/openai/trait'; import { openAIBase } from '#human/llm/requester/bases/openai/requester'; import { openAIResponsesBase } from '#human/llm/requester/bases/openai-responses/requester'; import { KimiFiles } from '#human/llm-kimi/files'; @@ -20,14 +23,14 @@ import { KIMI_DEFAULT_BASE_URL } from '#human/llm-kimi/trait'; import type { Model } from '../model/catalog'; import type { ResolvedLlmModel } from '../model/model-requester-impl'; import { - anthropicEndpointTrait, - geminiEndpointTrait, + anthropicConnection, + geminiConnection, getProviderDefinition, - openAIEndpointTrait, - vertexEndpointTrait, + openAIConnection, + vertexConnection, } from '../provider/provider-definition'; -import { IProtocolAdapterRegistry, type ExplainedCapability, type Protocol } from './protocol'; +import { IProtocolAdapterRegistry, type Protocol } from './protocol'; import { getProtocolBase, listProtocolBases, type ProtocolBaseId } from './protocol-base'; const vertexGenAIBase = createGoogleGenAIBase({ vertexai: true }); @@ -42,63 +45,92 @@ const kimiMedia: ProviderMediaContribution = { }; interface AdapterRoute { - readonly base: ProtocolBase; - readonly trait?: ProtocolTrait; + readonly base: ProtocolBase; + readonly trait?: AnyProtocolTrait; + readonly connection?: ProviderConnection; + readonly convertError?: LlmErrorClassifier; readonly providerId: string; readonly media?: ProviderMediaContribution; } +function openAIReasoningTraitFor(model: Model): OpenAITrait | undefined { + const reasoningKey = model.providerOptions?.reasoningKey ?? model.reasoningKey; + return reasoningKey === undefined ? undefined : { reasoningKey }; +} + function routeFor(model: Model): AdapterRoute { const definition = model.providerType === undefined ? undefined : getProviderDefinition(model.providerType, model.protocol); - const routeTrait = definition?.routeTrait; const routeMedia = definition?.modelSource === 'oauth-catalog' ? kimiMedia : undefined; + const custom = + definition !== undefined && + (definition.trait !== undefined || + definition.connection !== undefined || + definition.convertError !== undefined) + ? definition + : undefined; switch (model.protocol) { case 'openai': - return routeTrait !== undefined - ? { base: openAIBase, trait: routeTrait, providerId: 'openai', media: routeMedia } + return custom !== undefined + ? { + base: openAIBase, + trait: custom.trait, + connection: custom.connection, + convertError: custom.convertError, + providerId: 'openai', + media: routeMedia, + } : { base: openAIBase, - trait: openAITraitFor(model), + trait: openAIReasoningTraitFor(model), + connection: openAIConnection, providerId: 'openai', }; case 'openai_responses': - return routeTrait !== undefined - ? { base: openAIResponsesBase, trait: routeTrait, providerId: 'openai-responses', media: routeMedia } + return custom !== undefined + ? { + base: openAIResponsesBase, + trait: custom.trait, + connection: custom.connection, + convertError: custom.convertError, + providerId: 'openai-responses', + media: routeMedia, + } : { base: openAIResponsesBase, - trait: openAITraitFor(model), + connection: openAIConnection, providerId: 'openai-responses', }; case 'anthropic': { const base = model.providerOptions?.betaApi === true ? anthropicBetaBase : anthropicBase; - return routeTrait !== undefined - ? { base, trait: routeTrait, providerId: 'anthropic', media: routeMedia } - : { base, trait: anthropicEndpointTrait, providerId: 'anthropic' }; + return custom !== undefined + ? { + base, + trait: custom.trait, + connection: custom.connection, + convertError: custom.convertError, + providerId: 'anthropic', + media: routeMedia, + } + : { base, connection: anthropicConnection, providerId: 'anthropic' }; } case 'google-genai': return model.providerOptions?.vertexai === true ? { base: vertexGenAIBase, - trait: vertexEndpointTrait, + connection: vertexConnection, providerId: 'google_genai', } : { base: googleGenAIBase, - trait: geminiEndpointTrait, + connection: geminiConnection, providerId: 'google_genai', }; } } -function openAITraitFor(model: Model): ProtocolTrait { - const reasoningKey = model.providerOptions?.reasoningKey ?? model.reasoningKey; - if (reasoningKey === undefined) return openAIEndpointTrait; - return { ...openAIEndpointTrait, reasoningKey: () => reasoningKey }; -} - export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { declare readonly _serviceBrand: undefined; @@ -110,12 +142,7 @@ export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { const definition = providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); const baseId: ProtocolBaseId = definition?.baseProtocol ?? protocol; - const traits = definition?.traits ?? []; - const context = { - config: { protocol, providerType, modelName: '' }, - providerId: providerType, - }; - return { baseId, traits: traits.map((trait) => ({ trait, context })) }; + return { baseId, trait: definition?.trait }; } resolveProviderBaseId(protocol: Protocol, providerType?: string): ProtocolBaseId { @@ -125,49 +152,27 @@ export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { } resolveCapability(protocol: Protocol, modelName: string, providerType?: string): ModelCapability { - return this.explainCapability(protocol, modelName, providerType).capability; - } - - explainCapability( - protocol: Protocol, - modelName: string, - providerType?: string, - ): ExplainedCapability { const identity = this.resolveAdapterIdentity(protocol, providerType); - let traitCapability: ModelCapability | undefined; - for (const { trait } of identity.traits) { - if (trait.capability === undefined) continue; - const capability = trait.capability(modelName); - if (capability !== undefined) { - traitCapability = toV2Capability(capability); - } - } - if (traitCapability !== undefined) { - return { - capability: traitCapability, - source: { - kind: 'builtin', - detail: `trait capability hook (provider '${providerType ?? 'unregistered'}')`, - }, - }; + const definition = + providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); + const hooked = definition?.capability?.(modelName); + if (hooked !== undefined) { + return toV2Capability(hooked); } - const baseCapability = getProtocolBase(identity.baseId)?.base.capability?.(modelName); if (baseCapability !== undefined) { - return { - capability: toV2Capability(baseCapability), - source: { kind: 'builtin', detail: `protocol base '${identity.baseId}' catalog` }, - }; + return toV2Capability(baseCapability); } - return { - capability: UNKNOWN_CAPABILITY, - source: { kind: 'none', detail: 'no capability source knew this model' }, - }; + return UNKNOWN_CAPABILITY; } resolve(model: Model): ResolvedLlmModel { const route = routeFor(model); - const requester = route.base.createRequester(route.trait); + const requester = route.base.createRequester({ + connection: route.connection, + trait: route.trait, + convertError: route.convertError, + }); const llmModel: LlmModel & ModelThinkingMetadata = { provider: route.providerId, model: model.name, diff --git a/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts index 7e82634e812..82cb0e4534d 100644 --- a/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts +++ b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts @@ -1,29 +1,43 @@ import { BugIndicatingError } from '#/_base/errors/errors'; -import type { ProtocolEndpoint, ProtocolTrait } from '#human/llm/protocol/trait'; +import type { ModelCapability as HumanModelCapability } from '#human/llm/capability'; +import type { ProtocolEndpoint, ProviderConnection } from '#human/llm/protocol/connection'; +import type { ProtocolTraitMap } from '#human/llm/provider/definition'; +import type { LlmErrorClassifier } from '#human/llm/requester/requester'; import { kimiAnthropicTrait, + kimiConnection, kimiOpenAITrait, - kimiResponsesTrait, KIMI_DEFAULT_BASE_URL, } from '#human/llm-kimi/trait'; +import { classifyKimiQuotaError } from '#human/llm-kimi/errors'; import type { Protocol } from '../protocol/protocol'; import type { ModelSource } from './provider'; -export const openAIEndpointTrait: ProtocolTrait = { +export const openAIConnection: ProviderConnection = { endpoint: () => ({ apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }), }; -export const anthropicEndpointTrait: ProtocolTrait = { +export const anthropicConnection: ProviderConnection = { endpoint: () => ({ apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }), }; -export const geminiEndpointTrait: ProtocolTrait = { - endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }), +export const geminiEndpoint: ProtocolEndpoint = { + apiKeyEnv: 'GOOGLE_API_KEY', + baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL', }; -export const vertexEndpointTrait: ProtocolTrait = { - endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }), +export const vertexEndpoint: ProtocolEndpoint = { + apiKeyEnv: 'VERTEXAI_API_KEY', + baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL', +}; + +export const geminiConnection: ProviderConnection = { + endpoint: () => geminiEndpoint, +}; + +export const vertexConnection: ProviderConnection = { + endpoint: () => vertexEndpoint, }; export const kimiEndpoint: ProtocolEndpoint = { @@ -32,19 +46,24 @@ export const kimiEndpoint: ProtocolEndpoint = { defaultBaseUrl: KIMI_DEFAULT_BASE_URL, }; -export interface ProviderDefinition { +export interface ProviderDefinition { readonly id: string; - readonly baseProtocol: Protocol; - readonly traits: readonly ProtocolTrait[]; - readonly routeTrait?: ProtocolTrait; + readonly baseProtocol: N; + readonly trait?: ProtocolTraitMap[N]; + readonly connection?: ProviderConnection; + readonly convertError?: LlmErrorClassifier; + readonly capability?: (modelName: string) => HumanModelCapability | undefined; readonly endpoint?: ProtocolEndpoint; + readonly endpoints?: readonly ProtocolEndpoint[]; readonly hostHeaders?: 'full' | 'user-agent'; readonly modelSource?: ModelSource; } const providerDefinitions = new Map>(); -export function registerProviderDefinition(definition: ProviderDefinition): void { +export function registerProviderDefinition( + definition: ProviderDefinition, +): void { let byProtocol = providerDefinitions.get(definition.id); if (byProtocol === undefined) { byProtocol = new Map(); @@ -108,7 +127,7 @@ export function explainProviderEndpoint( const definition = getProviderDefinition(providerType); if (definition === undefined) return {}; const endpoint = - normalizeEndpointDeclaration(definition.endpoint) ?? aggregateTraitEndpoints(definition); + normalizeEndpointDeclaration(definition.endpoint) ?? aggregateEndpoints(definition.endpoints); if (endpoint === undefined) return {}; const apiKeyHit = firstEnvHit(endpoint.apiKeyEnv, env); const baseUrlHit = firstEnvHit(endpoint.baseUrlEnv, env); @@ -152,23 +171,19 @@ function normalizeEndpointDeclaration( }; } -function aggregateTraitEndpoints( - definition: ProviderDefinition, +function aggregateEndpoints( + endpoints: readonly ProtocolEndpoint[] | undefined, ): AggregatedEndpointDeclaration | undefined { + if (endpoints === undefined || endpoints.length === 0) return undefined; const apiKeyEnv: string[] = []; const baseUrlEnv: string[] = []; let defaultBaseUrl: string | undefined; - let declared = false; - for (const trait of definition.traits) { - if (trait.endpoint === undefined) continue; - const endpoint = trait.endpoint(); - if (endpoint === undefined) continue; - declared = true; + for (const endpoint of endpoints) { if (endpoint.apiKeyEnv !== undefined) apiKeyEnv.push(endpoint.apiKeyEnv); if (endpoint.baseUrlEnv !== undefined) baseUrlEnv.push(endpoint.baseUrlEnv); if (endpoint.defaultBaseUrl !== undefined) defaultBaseUrl = endpoint.defaultBaseUrl; } - return declared ? { apiKeyEnv, baseUrlEnv, defaultBaseUrl } : undefined; + return { apiKeyEnv, baseUrlEnv, defaultBaseUrl }; } function firstEnvHit( @@ -185,35 +200,33 @@ function firstEnvHit( registerProviderDefinition({ id: 'anthropic', baseProtocol: 'anthropic', - traits: [], endpoint: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }, }); registerProviderDefinition({ id: 'openai', baseProtocol: 'openai', - traits: [], endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, }); registerProviderDefinition({ id: 'openai_responses', baseProtocol: 'openai_responses', - traits: [], endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, }); registerProviderDefinition({ id: 'google-genai', baseProtocol: 'google-genai', - traits: [vertexEndpointTrait, geminiEndpointTrait], + endpoints: [vertexEndpoint, geminiEndpoint], }); registerProviderDefinition({ id: 'kimi', baseProtocol: 'openai', - traits: [kimiOpenAITrait], - routeTrait: kimiOpenAITrait, + trait: kimiOpenAITrait, + connection: kimiConnection, + convertError: classifyKimiQuotaError, endpoint: kimiEndpoint, hostHeaders: 'full', modelSource: 'oauth-catalog', @@ -222,8 +235,9 @@ registerProviderDefinition({ registerProviderDefinition({ id: 'kimi', baseProtocol: 'anthropic', - traits: [kimiAnthropicTrait], - routeTrait: kimiAnthropicTrait, + trait: kimiAnthropicTrait, + connection: kimiConnection, + convertError: classifyKimiQuotaError, endpoint: kimiEndpoint, hostHeaders: 'full', modelSource: 'oauth-catalog', @@ -232,8 +246,8 @@ registerProviderDefinition({ registerProviderDefinition({ id: 'kimi', baseProtocol: 'openai_responses', - traits: [kimiResponsesTrait], - routeTrait: kimiResponsesTrait, + connection: kimiConnection, + convertError: classifyKimiQuotaError, endpoint: kimiEndpoint, hostHeaders: 'full', modelSource: 'oauth-catalog', diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index 9591a2e5b99..38555dae335 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -1,3 +1,4 @@ +import { createReadStream } from 'node:fs'; import { appendFile, lstat, @@ -12,7 +13,7 @@ import { } from 'node:fs/promises'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { decodeTextWithErrors, type TextDecodeErrors } from '#/_base/execEnv/decodeText'; +import { decodeTextWithErrors, readUtf8Lines, type TextDecodeErrors } from '#/_base/execEnv/decodeText'; import { type HostDirEntry, type HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; import { toHostFsError } from '#/os/interface/hostFsErrors'; @@ -114,57 +115,12 @@ export class HostFileSystem implements IHostFileSystem { return; } - yield* this._readUtf8Lines(path, errors); + yield* readUtf8Lines(createReadStream(path, { highWaterMark: READ_CHUNK_SIZE }), errors); } catch (error) { throw toHostFsError(error, { path, op: 'read' }); } } - private async *_readUtf8Lines( - path: string, - errors: TextDecodeErrors, - ): AsyncGenerator { - const fh = await open(path, 'r'); - try { - const buf = Buffer.alloc(READ_CHUNK_SIZE); - let pending: Buffer[] = []; - let pendingOffset = 0; - let fileOffset = 0; - - while (true) { - const { bytesRead } = await fh.read(buf, 0, buf.length, null); - if (bytesRead === 0) break; - const chunk = buf.subarray(0, bytesRead); - let lineStart = 0; - - for (let i = 0; i < chunk.length; i += 1) { - const byte = chunk[i]; - if (byte !== 0x0a) continue; - const piece = chunk.subarray(lineStart, i + 1); - const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset; - const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]); - yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0); - pending = []; - lineStart = i + 1; - } - - if (lineStart < chunk.length) { - const tail = Buffer.from(chunk.subarray(lineStart)); - if (pending.length === 0) pendingOffset = fileOffset + lineStart; - pending.push(tail); - } - fileOffset += bytesRead; - } - - if (pending.length > 0) { - const line = Buffer.concat(pending); - yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0); - } - } finally { - await fh.close(); - } - } - async createExclusive(path: string, data: Uint8Array): Promise { try { const fh = await open(path, 'wx'); diff --git a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts index 08de319873e..f6313548c27 100644 --- a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts @@ -1,9 +1,7 @@ -import { promises as fsp } from 'node:fs'; - import { join } from 'pathe'; -import { type QueryOptions } from '@moonshot-ai/minidb'; -import { ClusterDb } from '@moonshot-ai/minidb/cluster'; +import { classifyStorageError, type QueryOptions } from '@moonshot-ai/minidb'; +import { ClusterDb, wipeCluster } from '@moonshot-ai/minidb/cluster'; import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; @@ -12,6 +10,7 @@ import { ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IQueryStore, + QueryStoreRebuiltError, type Checkpoint, type ColumnBounds, type ColumnPageQuery, @@ -29,6 +28,7 @@ const STORE_SUBDIR = 'query-store'; const SHARD_COUNT = 16; const LOCK_ACQUIRE_TIMEOUT_MS = 1000; const DROP_BATCH_SIZE = 500; +const TRANSIENT_ESCALATION_LIMIT = 5; function physicalKey(collection: string, key: string): string { return `${collection}${SEP}${key}`; @@ -38,10 +38,6 @@ function indexName(collection: string, name: string): string { return `${collection}:${name}`; } -function isRebuildable(error: unknown): boolean { - return error instanceof SyntaxError || (error as { name?: string }).name === 'CorruptFrameError'; -} - const pendingDisposals = new Set>(); export async function drainQueryStoreDisposals(): Promise { @@ -54,6 +50,9 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { private readonly dir: string; private dbPromise: Promise | undefined; private rebuildPromise: Promise | undefined; + private transientReadFailures = 0; + private transientWriteFailures = 0; + private storeEpochCounter = 0; private readonly ensuredIndexes = new Set(); constructor( @@ -95,7 +94,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { private rebuild(cause: unknown): Promise { this.rebuildPromise ??= (async () => { - this.log.warn('minidb query-store rebuilt after corruption', { + this.log.warn('minidb query-store rebuilt after unrecoverable failure', { dir: this.dir, error: String(cause), }); @@ -106,18 +105,52 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { const db = await previous.catch(() => undefined); await db?.close().catch(() => {}); } - await fsp.rm(this.dir, { recursive: true, force: true }); + const outcome = await wipeCluster({ + dir: this.dir, + lockAcquireTimeoutMs: LOCK_ACQUIRE_TIMEOUT_MS, + }); + if (outcome === 'locked') throw cause; + this.storeEpochCounter += 1; })(); - return this.rebuildPromise; + const settled = this.rebuildPromise; + return settled.then( + () => { + if (this.rebuildPromise === settled) this.rebuildPromise = undefined; + }, + (error: unknown) => { + if (this.rebuildPromise === settled) this.rebuildPromise = undefined; + throw error; + }, + ); } - private async withDb(op: (db: ClusterDb) => Promise): Promise { + private async withDb( + op: (db: ClusterDb) => Promise, + kind: 'read' | 'write', + expectedStoreEpoch?: number, + ): Promise { + const db = await this.openDb(); + if (expectedStoreEpoch !== undefined && expectedStoreEpoch !== this.storeEpochCounter) { + throw new QueryStoreRebuiltError(); + } try { - return await op(await this.openDb()); + const result = await op(db); + if (kind === 'write') this.transientWriteFailures = 0; + else this.transientReadFailures = 0; + return result; } catch (error) { - if (!isRebuildable(error)) throw error; + if (classifyStorageError(error) !== 'rebuild') { + const failures = + kind === 'write' + ? (this.transientWriteFailures += 1) + : (this.transientReadFailures += 1); + if (failures < TRANSIENT_ESCALATION_LIMIT) throw error; + } + this.transientReadFailures = 0; + this.transientWriteFailures = 0; await this.rebuild(error); - return op(await this.openDb()); + if (expectedStoreEpoch !== undefined) throw new QueryStoreRebuiltError(); + throw error; } } @@ -127,41 +160,45 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { value: T, options?: { columns?: Record }, ): Promise { - await this.withDb((db) => - db.set(physicalKey(collection, key), value, { dt: options?.columns }), + await this.withDb( + (db) => db.set(physicalKey(collection, key), value, { dt: options?.columns }), + 'write', ); } async batch(ops: readonly WriteOp[]): Promise { if (ops.length === 0) return; - await this.withDb((db) => - db.batch( - ops.map((op) => - op.kind === 'put' - ? { - op: 'set' as const, - key: physicalKey(op.collection, op.key), - value: op.value, - dt: op.columns, - } - : { op: 'del' as const, key: physicalKey(op.collection, op.key) }, + await this.withDb( + (db) => + db.batch( + ops.map((op) => + op.kind === 'put' + ? { + op: 'set' as const, + key: physicalKey(op.collection, op.key), + value: op.value, + dt: op.columns, + } + : { op: 'del' as const, key: physicalKey(op.collection, op.key) }, + ), ), - ), + 'write', ); } async delete(collection: string, key: string): Promise { - await this.withDb((db) => db.del(physicalKey(collection, key))); + await this.withDb((db) => db.del(physicalKey(collection, key)), 'write'); } async get(collection: string, key: string): Promise { - return this.withDb((db) => db.get(physicalKey(collection, key)) as Promise); + return this.withDb((db) => db.get(physicalKey(collection, key)) as Promise, 'read'); } async getMany(collection: string, keys: readonly string[]): Promise> { if (keys.length === 0) return new Map(); - const values = await this.withDb((db) => - db.mget(keys.map((key) => physicalKey(collection, key))), + const values = await this.withDb( + (db) => db.mget(keys.map((key) => physicalKey(collection, key))), + 'read', ); const out = new Map(); values.forEach((value, index) => { @@ -172,36 +209,39 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { async pageByColumn(collection: string, query: ColumnPageQuery): Promise> { const dir = query.dir ?? 'asc'; - const rows = (await this.withDb((db) => - db.query({ - dt: { [query.column]: query.bounds ?? {} }, - filter: query.filter as Record | undefined, - sort: { [query.column]: dir === 'desc' ? -1 : 1 }, - limit: query.limit, - }), + const rows = (await this.withDb( + (db) => + db.query({ + dt: { [query.column]: query.bounds ?? {} }, + filter: query.filter as Record | undefined, + sort: { [query.column]: dir === 'desc' ? -1 : 1 }, + limit: query.limit, + }), + 'read', )) as ReadonlyArray<{ value: T }>; return { items: rows.map((row) => row.value) }; } async listKeys(collection: string): Promise { const prefix = `${collection}${SEP}`; - const entries = await this.withDb((db) => db.scan({ prefix })); + const entries = await this.withDb((db) => db.scan({ prefix }), 'read'); return entries.map((entry) => entry.key.slice(prefix.length)); } async dropCollection(collection: string): Promise { const prefix = `${collection}${SEP}`; - const entries = await this.withDb((db) => db.scan({ prefix })); + const entries = await this.withDb((db) => db.scan({ prefix }), 'read'); for (let start = 0; start < entries.length; start += DROP_BATCH_SIZE) { const chunk = entries.slice(start, start + DROP_BATCH_SIZE); - await this.withDb((db) => - db.batch(chunk.map((entry) => ({ op: 'del' as const, key: entry.key }))), + await this.withDb( + (db) => db.batch(chunk.map((entry) => ({ op: 'del' as const, key: entry.key }))), + 'write', ); } } query(collection: string): IQuery { - return new MiniDbQuery((op) => this.withDb(op), collection); + return new MiniDbQuery((op) => this.withDb(op, 'read'), collection); } async ensureIndex(collection: string, def: IndexDef): Promise { @@ -223,7 +263,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { } catch (error) { if (!(error instanceof Error) || !error.message.includes('already exists')) throw error; } - }); + }, 'write'); this.ensuredIndexes.add(guard); } @@ -231,8 +271,20 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { return this.get(CHECKPOINT_COLLECTION, source); } - async setCheckpoint(source: string, checkpoint: Checkpoint): Promise { - await this.put(CHECKPOINT_COLLECTION, source, checkpoint); + async setCheckpoint( + source: string, + checkpoint: Checkpoint, + expectedStoreEpoch?: number, + ): Promise { + await this.withDb( + (db) => db.set(physicalKey(CHECKPOINT_COLLECTION, source), checkpoint), + 'write', + expectedStoreEpoch, + ); + } + + storeEpoch(): number { + return this.storeEpochCounter; } async close(): Promise { diff --git a/packages/agent-core-v2/src/persistence/interface/queryStore.ts b/packages/agent-core-v2/src/persistence/interface/queryStore.ts index 90f3200c392..f29f91234b6 100644 --- a/packages/agent-core-v2/src/persistence/interface/queryStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/queryStore.ts @@ -66,7 +66,15 @@ export type WriteOp = export interface Checkpoint { readonly seq: number; - readonly sourceMaxMtimeMs?: number; + readonly sourceSessionCount?: number; + readonly schemaVersion?: number; +} + +export class QueryStoreRebuiltError extends Error { + constructor() { + super('the query-store was rebuilt while the operation was in flight'); + this.name = 'QueryStoreRebuiltError'; + } } export interface ColumnBounds { @@ -103,7 +111,8 @@ export interface IQueryStore { listKeys(collection: string): Promise; dropCollection(collection: string): Promise; getCheckpoint(source: string): Promise; - setCheckpoint(source: string, checkpoint: Checkpoint): Promise; + setCheckpoint(source: string, checkpoint: Checkpoint, expectedStoreEpoch?: number): Promise; + storeEpoch(): number; close(): Promise; } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 7d995388626..1696ffecced 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -388,7 +388,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle managed.closing = true; this.onWillCloseEmitter.fire(agent); const handle = managed.handle; - await handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed'); + await handle.accessor.get(IAgentTaskService).suppressAllTerminalNotifications(); const loop = handle.accessor.get(IAgentLoopService); const compaction = handle.accessor.get(IAgentFullCompactionService).compacting; const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve(); @@ -400,8 +400,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle const promptIdleDeadline = Date.now() + REMOVE_PROMPT_QUIESCE_TIMEOUT_MS; let releaseQuiescence: (() => void) | undefined; for (;;) { - for (const turnId of loop.status().pendingTurnIds) { - loop.cancel(turnId, reason); + for (const queueId of loop.status().pendingPromptIds) { + loop.cancelQueued(queueId, reason); } loop.cancel(undefined, reason); await Promise.all([loop.settled(), compactionSettled, prompt.drain(reason)]); @@ -428,6 +428,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle await new Promise((resolve) => setTimeout(resolve, REMOVE_PROMPT_QUIESCE_POLL_MS)); } try { + await handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed'); await handle.accessor.get(IEventDispatcher).flush().catch(onUnexpectedError); managed.killSpace(); await handle.dispose(); diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts index d5dc4ae8047..58bef720586 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts @@ -1,4 +1,4 @@ -import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Disposable, DisposableStore, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, @@ -8,12 +8,19 @@ import { import { Emitter, type Event } from '#/_base/event'; import { defineState } from '#/state/state'; import { IEventBus } from '#/app/event/eventBus'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { TurnStarted, type TurnEndReason } from '#/agent/loop/turnEvents'; +import { TurnEnded, turnKey } from '#/agent/loop/turnOps'; +import { IAgentTaskService } from '#/agent/task/task'; +import { TaskStarted, TaskTerminatedNotice } from '#/agent/task/taskOps'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { - AgentActivityUpdated, - IAgentActivityView, - type AgentActivityState, -} from '#/agent/activityView/activityView'; -import type { TurnEndReason } from '#/agent/loop/turnEvents'; + CompactionCancelled, + CompactionCompleted, + CompactionStarted, +} from '#/agent/fullCompaction/compactionOps'; +import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { INTERACTION_TAG_SESSION_ID, @@ -34,7 +41,8 @@ import { interface AgentWorkFold { turnActive: boolean; - background: number; + background: ReadonlySet; + compacting: boolean; lastTurnReason?: SessionTurnOutcome; } @@ -114,28 +122,85 @@ export class SessionActivityView extends Disposable implements ISessionActivityV private attachAgent(handle: IAgentScopeHandle): void { if (this.folds.has(handle.id)) return; - const view = handle.accessor.get(IAgentActivityView) as IAgentActivityView | undefined; - this.folds.set(handle.id, foldOf(handle.id, view?.state())); + this.folds.set(handle.id, seedFold(handle)); const bus = handle.accessor.get(IEventBus) as IEventBus | undefined; if (bus === undefined) return; - this.agentSubscriptions.set( - handle.id, - bus.subscribe(AgentActivityUpdated, (event) => this.onActivity(handle.id, event)), + const subscriptions = new DisposableStore(); + subscriptions.add( + bus.subscribe(TurnStarted, () => + this.patchFold(handle.id, (fold) => ({ + ...fold, + turnActive: true, + lastTurnReason: handle.id === MAIN_AGENT_ID ? undefined : fold.lastTurnReason, + })), + ), + ); + subscriptions.add( + bus.subscribe(TurnEnded, (event) => + this.patchFold(handle.id, (fold) => ({ + ...fold, + turnActive: false, + lastTurnReason: handle.id === MAIN_AGENT_ID ? mapTurnReason(event.reason) : fold.lastTurnReason, + })), + ), + ); + subscriptions.add( + bus.subscribe(TaskStarted, (event) => + this.patchFold(handle.id, (fold) => ({ + ...fold, + background: new Set(fold.background).add(event.info.taskId), + })), + ), + ); + subscriptions.add( + bus.subscribe(TaskTerminatedNotice, (event) => + this.patchFold(handle.id, (fold) => { + if (!fold.background.has(event.info.taskId)) return fold; + const background = new Set(fold.background); + background.delete(event.info.taskId); + return { ...fold, background }; + }), + ), + ); + subscriptions.add( + bus.subscribe(CompactionStarted, () => + this.patchFold(handle.id, (fold) => ({ ...fold, compacting: true })), + ), + ); + subscriptions.add( + bus.subscribe(CompactionCompleted, () => + this.patchFold(handle.id, (fold) => ({ ...fold, compacting: false })), + ), + ); + subscriptions.add( + bus.subscribe(CompactionCancelled, () => + this.patchFold(handle.id, (fold) => ({ ...fold, compacting: false })), + ), ); + const dispatcher = handle.accessor.get(IEventDispatcher) as IEventDispatcher | undefined; + if (dispatcher !== undefined) { + subscriptions.add( + dispatcher.hooks.onDidRestore.register('sessionActivity', async (_ctx, next) => { + this.folds.set(handle.id, seedFold(handle)); + this.recompute('agent_lifecycle'); + await next(); + }), + ); + } + this.agentSubscriptions.set(handle.id, subscriptions); } - private onActivity(agentId: string, snapshot: AgentActivityState): void { + private patchFold(agentId: string, patch: (fold: AgentWorkFold) => AgentWorkFold): void { const previous = this.folds.get(agentId); - const next = foldOf(agentId, snapshot, previous); + if (previous === undefined) return; + const next = patch(previous); this.folds.set(agentId, next); - if (previous === undefined) { - this.recompute('agent_lifecycle'); - return; - } let cause: SessionActivityCause | undefined; if (!previous.turnActive && next.turnActive) cause = 'turn_started'; else if (previous.turnActive && !next.turnActive) cause = 'turn_ended'; - else if (previous.background !== next.background) cause = 'background'; + else if (previous.background.size !== next.background.size || previous.compacting !== next.compacting) { + cause = 'background'; + } else if (agentId === MAIN_AGENT_ID && previous.lastTurnReason !== next.lastTurnReason) { cause = 'turn_ended'; } @@ -152,7 +217,7 @@ export class SessionActivityView extends Disposable implements ISessionActivityV private aggregate(): SessionActivityState { let busy = false; for (const fold of this.folds.values()) { - if (fold.turnActive || fold.background > 0) { + if (fold.turnActive || fold.background.size > 0 || fold.compacting) { busy = true; break; } @@ -171,16 +236,23 @@ export class SessionActivityView extends Disposable implements ISessionActivityV } } -function foldOf( - agentId: string, - activity: AgentActivityState | undefined, - previous?: AgentWorkFold, -): AgentWorkFold { +function seedFold(handle: IAgentScopeHandle): AgentWorkFold { + const loop = handle.accessor.get(IAgentLoopService) as IAgentLoopService | undefined; + const tasks = handle.accessor.get(IAgentTaskService) as IAgentTaskService | undefined; + const compaction = handle.accessor.get(IAgentFullCompactionService) as + | IAgentFullCompactionService + | undefined; + const states = handle.accessor.get(IAgentStateService) as IAgentStateService | undefined; + const lastEnded = + handle.id === MAIN_AGENT_ID && states?.has(turnKey) === true + ? states.get(turnKey).lastEnded + : undefined; return { - turnActive: activity?.turn !== undefined, - background: activity?.background?.length ?? 0, + turnActive: loop?.status().state === 'running', + background: new Set(tasks?.list(true).map((task) => task.taskId) ?? []), + compacting: (compaction?.compacting ?? null) !== null, lastTurnReason: - agentId === MAIN_AGENT_ID ? mapTurnReason(activity?.lastTurn?.reason) : previous?.lastTurnReason, + loop?.status().state === 'running' ? undefined : mapTurnReason(lastEnded?.reason), }; } diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts index f229e32cc24..0b3a0fb8116 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts @@ -2,7 +2,6 @@ import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; import { IEventBus } from '#/app/event/eventBus'; -import { AgentActivityUpdated } from '#/agent/activityView/activityView'; import { TurnStarted } from '#/agent/loop/turnEvents'; import { TurnEnded, turnKey } from '#/agent/loop/turnOps'; import { ContextUndone } from '#/agent/undo/undoService'; @@ -105,26 +104,36 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM this.write(undefined); }), ); - subscription.add( - bus.subscribe(AgentActivityUpdated, (event) => { - if (this.turnStartedHere) return; - if (this.lastPersisted !== undefined) return; - const reason = event.lastTurn?.reason; - if (reason === 'completed' || reason === 'cancelled') { - this.write(reason, { touchUpdatedAt: false, turnId: event.lastTurn?.turnId }); - } else if (reason === 'failed' || reason === 'blocked') { - this.write('failed', { touchUpdatedAt: false, turnId: event.lastTurn?.turnId }); - } - }), - ); + this.seedFromWire(agentStates); + } + + private seedFromWire(agentStates: IAgentStateService | undefined): void { + if (agentStates === undefined || !agentStates.has(turnKey)) return; + const lastEnded = agentStates.get(turnKey).lastEnded; + if (lastEnded === undefined) return; + void this.metadataReady.then(() => { + if (this.turnStartedHere || this.lastPersisted !== undefined) return; + this.adoptLastEnded(lastEnded); + }); + } + + private adoptLastEnded(lastEnded: { turnId: number; reason: string }): void { + if (lastEnded.reason === 'completed' || lastEnded.reason === 'cancelled') { + this.write(lastEnded.reason, { touchUpdatedAt: false, turnId: lastEnded.turnId }); + return; + } + this.write('failed', { touchUpdatedAt: false, turnId: lastEnded.turnId }); } private async reconcileAfterRestore(agentStates: IAgentStateService): Promise { await this.metadataReady; - if (this.lastPersisted === undefined) return; if (this.turnStartedHere) return; if (!agentStates.has(turnKey)) return; const lastEnded = agentStates.get(turnKey).lastEnded; + if (this.lastPersisted === undefined) { + if (lastEnded !== undefined) this.adoptLastEnded(lastEnded); + return; + } if (lastEnded === undefined) { this.write(undefined, { touchUpdatedAt: false }); return; diff --git a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts index 39dadbfe419..0e66e519acc 100644 --- a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts @@ -7,7 +7,6 @@ import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { Error2, ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { - IAgentLoopService, isMaxStepsExceededError, type Turn, type TurnResult, @@ -65,9 +64,8 @@ async function awaitRun( ): Promise { const controller = new AbortController(); const unlink = linkAbortSignal(options.signal, controller); - const loop = target.accessor.get(IAgentLoopService); const cancelTurn = (reason: unknown): void => { - loop.cancel(turn.id, reason); + turn.cancel(reason); }; try { const result = classifyTurnResult(await awaitTurn(turn, controller, cancelTurn)); diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index af8db5f7417..e319c4fa1c8 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -71,6 +71,7 @@ export interface ExecutableToolContext { readonly trace?: LLMRequestTrace; readonly metadata?: unknown; readonly signal: AbortSignal; + readonly steerSignal?: AbortSignal; readonly onUpdate?: ((update: ToolUpdate) => void) | undefined; readonly onForegroundTaskStart?: ((taskId: string) => void) | undefined; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 5b7b932ef2a..5d48bcc216f 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -91,7 +91,6 @@ import { IWorkspaceAgentProfileLoader, } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { IAgentActivityView } from '#/agent/activityView/activityView'; import { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; @@ -511,7 +510,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec for (const agent of sourceAgents.list()) { const agentHandle = sourceAgents.handleOf(agent.agentId); if (agentHandle === undefined) continue; - if (agentHandle.accessor.get(IAgentActivityView).state().turn !== undefined) { + if (agentHandle.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error2( ErrorCodes.SESSION_FORK_ACTIVE_TURN, `Session "${sourceId}" cannot be forked while a turn is running`, diff --git a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts b/packages/agent-core-v2/test/agent/activityView/activityView.test.ts deleted file mode 100644 index f982e260fda..00000000000 --- a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { SyncDescriptor } from '#/_base/di/descriptors'; -import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; -import { TestInstantiationService } from '#/_base/di/test'; -import { IEventBus } from '#/app/event/eventBus'; -import type { Event2, Event2Class } from '#/app/event/event2'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { TurnStarted } from '#/agent/loop/turnEvents'; -import { TurnEnded, turnKey, type TurnModelState } from '#/agent/loop/turnOps'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { AgentStateService } from '#/agent/state/agentStateService'; -import { IAgentTaskService } from '#/agent/task/task'; -import { TaskStarted, TaskTerminatedNotice } from '#/agent/task/taskOps'; -import type { AgentTaskInfo } from '#/agent/task/types'; -import { - CompactionCancelled, - CompactionStarted, -} from '#/agent/fullCompaction/compactionOps'; -import { AgentActivityView } from '#/agent/activityView/activityViewService'; -import { IAgentActivityView, type AgentActivityState } from '#/agent/activityView/activityView'; -import { - PermissionApprovalRequested, - PermissionApprovalResolved, -} from '#/agent/toolApproval/toolApprovalService'; -import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; -import type { FullCompactionTask } from '#/agent/fullCompaction/fullCompaction'; -import { ContextUndone } from '#/agent/undo/undoService'; -import { OrderedHookSlot } from '#/hooks'; -import { IEventDispatcher } from '#/state/eventDispatcher'; -import { stubAgentContext } from '../agentContext/stubs'; - -class FakeBus { - private readonly byType = new Map void>>(); - private readonly all: Array<(e: Event2) => void> = []; - readonly published: Event2[] = []; - - publish(event: Event2): void { - this.published.push(event); - for (const h of this.all) h(event); - for (const h of this.byType.get(event.type) ?? []) h(event); - } - - subscribe(typeOrClass: unknown, handler?: unknown): IDisposable { - if (typeof typeOrClass === 'function' && !('type' in typeOrClass)) { - this.all.push(typeOrClass as (e: Event2) => void); - return { dispose: () => {} }; - } - const type = - typeof typeOrClass === 'string' ? typeOrClass : (typeOrClass as Event2Class).type; - const list = this.byType.get(type) ?? []; - list.push(handler as (e: Event2) => void); - this.byType.set(type, list); - return { dispose: () => {} }; - } -} - -function makeTaskInfo(taskId: string): AgentTaskInfo { - return { - taskId, - kind: 'process', - description: 'sleep 60', - status: 'running', - startedAt: 100, - endedAt: null, - command: 'sleep 60', - pid: 4242, - exitCode: null, - }; -} - -let disposables: DisposableStore; - -function harness( - seedTasks: readonly AgentTaskInfo[] = [], - compacting: FullCompactionTask | null = null, - lastEnded?: TurnModelState['lastEnded'], -) { - const bus = new FakeBus(); - const loop = { - status: () => ({ state: 'idle', pendingTurnIds: [], hasPendingRequests: false }), - } as unknown as IAgentLoopService; - const tasks = { list: () => seedTasks } as unknown as IAgentTaskService; - const restoreHooks: Array<() => Promise> = []; - const dispatcher = { - dispatch: async (event: Event2) => { - bus.publish(event); - }, - hooks: { - onDidRestore: { - register: (_id: string, fn: (ctx: undefined, next: () => Promise) => Promise) => { - restoreHooks.push(async () => fn(undefined, async () => {})); - return { dispose: () => {} }; - }, - }, - }, - } as unknown as IEventDispatcher; - const restore = async (ended: TurnModelState['lastEnded']): Promise => { - agentState.set(turnKey, { nextTurnId: 1, cancelledTurnIds: [], anchorTurnIds: [], lastEnded: ended }); - for (const hook of restoreHooks) await hook(); - }; - const ix = disposables.add(new TestInstantiationService()); - ix.stub(IEventBus, bus as unknown as IEventBus); - ix.stub(IAgentLoopService, loop); - ix.stub(IAgentTaskService, tasks); - ix.stub(IEventDispatcher, dispatcher); - const agentState = new AgentStateService(); - agentState.contributeState(turnKey); - agentState.set(turnKey, { nextTurnId: 1, cancelledTurnIds: [], anchorTurnIds: [], lastEnded }); - ix.set(IAgentStateService, agentState); - ix.stub(IAgentScopeContext, { - _serviceBrand: undefined, - agentId: 'main', - agentContext: stubAgentContext('main', 1), - scope: (subKey?: string) => subKey ?? '', - }); - ix.stub(IAgentFullCompactionService, { - _serviceBrand: undefined, - compacting, - } as unknown as IAgentFullCompactionService); - ix.set(IAgentActivityView, new SyncDescriptor(AgentActivityView)); - const view = ix.get(IAgentActivityView); - const updates = (): AgentActivityState[] => - bus.published - .filter((e) => e.type === 'agent.activity.updated') - .map((e) => e as unknown as AgentActivityState); - return { bus, view, updates, restore }; -} - -describe('AgentActivityView', () => { - beforeEach(() => { - disposables = new DisposableStore(); - }); - - afterEach(() => { - disposables.dispose(); - }); - - it('starts with an empty, not-busy snapshot', () => { - const { view } = harness(); - expect(view.state()).toEqual({ lifecycle: 'ready', background: [] }); - }); - - it('folds task.started / task.terminated into the background slice', () => { - const { bus, view, updates } = harness(); - - bus.publish(new TaskStarted({ agentId: 'main', info: makeTaskInfo('bash-1') })); - expect(view.state().background).toEqual([{ kind: 'process', id: 'bash-1', since: 100 }]); - expect(updates().at(-1)?.background).toHaveLength(1); - - bus.publish(new TaskTerminatedNotice({ agentId: 'main', info: makeTaskInfo('bash-1') })); - expect(view.state().background).toEqual([]); - expect(updates().at(-1)?.background).toHaveLength(0); - }); - - it('seeds the background slice from the task registry on creation', () => { - const { view } = harness([makeTaskInfo('bash-9')]); - expect(view.state().background).toEqual([{ kind: 'process', id: 'bash-9', since: 100 }]); - }); - - it('seeds lastTurn from the wire turnKey when the view is built after restore', () => { - const { view } = harness([], null, { turnId: 7, reason: 'failed', durationMs: 1234 }); - expect(view.state().lastTurn).toMatchObject({ turnId: 7, reason: 'failed', durationMs: 1234 }); - }); - - it('seeds lastTurn when the wire restore lands after construction (cold resume ordering)', async () => { - const { view, restore } = harness(); - expect(view.state().lastTurn).toBeUndefined(); - await restore({ turnId: 7, reason: 'failed', durationMs: 1234 }); - expect(view.state().lastTurn).toMatchObject({ turnId: 7, reason: 'failed', durationMs: 1234 }); - }); - - it('does not overwrite a live lastTurn when the restore hook runs', async () => { - const { bus, view, restore } = harness([], null, { turnId: 7, reason: 'failed' }); - bus.publish(new TurnEnded({ agentId: 'main', turnId: 9, reason: 'completed' })); - await restore({ turnId: 7, reason: 'failed' }); - expect(view.state().lastTurn).toMatchObject({ turnId: 9, reason: 'completed' }); - }); - - it('leaves lastTurn empty when the wire has no ended turn', () => { - const { view } = harness(); - expect(view.state().lastTurn).toBeUndefined(); - }); - - it('folds full compaction into the background slice', () => { - const { bus, view } = harness(); - - bus.publish(new CompactionStarted({ agentId: 'main', trigger: 'manual' })); - expect(view.state().background).toEqual([ - expect.objectContaining({ kind: 'compaction', id: 'full-compaction' }), - ]); - - bus.publish(new CompactionCancelled({ agentId: 'main' })); - expect(view.state().background).toEqual([]); - }); - - it('seeds an in-flight full compaction on creation', () => { - const compacting: FullCompactionTask = { - abortController: new AbortController(), - promise: new Promise(() => {}), - trigger: 'manual', - tokenCount: 100, - }; - - const { view } = harness([], compacting); - - expect(view.state().background).toEqual([ - expect.objectContaining({ kind: 'compaction', id: 'full-compaction' }), - ]); - }); - - it('folds turn boundaries into turn / lastTurn', () => { - const { bus, view } = harness(); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - expect(view.state().turn?.turnId).toBe(1); - - bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'completed' })); - expect(view.state().turn).toBeUndefined(); - expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'completed' }); - }); - - it('clears the previous outcome when a new turn starts', () => { - const { bus, view } = harness(); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'cancelled' })); - expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'cancelled' }); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 2, origin: { kind: 'user' } })); - expect(view.state().lastTurn).toBeUndefined(); - - bus.publish(new TurnEnded({ agentId: 'main', turnId: 2, reason: 'completed' })); - expect(view.state().lastTurn).toMatchObject({ turnId: 2, reason: 'completed' }); - }); - - it('clears the last outcome when an undo rewinds the turn it describes', () => { - const { bus, view } = harness(); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'cancelled' })); - expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'cancelled' }); - - bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 1 })); - expect(view.state().lastTurn).toBeUndefined(); - }); - - it('keeps the last outcome when an undo rewinds only later turns', () => { - const { bus, view } = harness(); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'completed' })); - - bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 2 })); - expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'completed' }); - }); - - it('clears the last outcome when the undo range cannot be determined', () => { - const { bus, view } = harness(); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'failed' })); - - bus.publish(new ContextUndone({ agentId: 'main', turns: 1 })); - expect(view.state().lastTurn).toBeUndefined(); - }); - - it('exposes the engine-minted interaction id as the approval id', () => { - const { bus, view } = harness(); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - bus.publish( - new PermissionApprovalRequested({ agentId: 'main', - id: 'approval_1', - sessionId: 's', - turnId: 1, - toolCallId: 'tc-1', - toolName: 'Bash', - action: 'run', - toolInput: {}, - display: { kind: 'command', command: 'ls' }, - }), - ); - expect(view.state().turn?.pendingApprovals).toEqual([ - { approvalId: 'approval_1', toolCallId: 'tc-1', since: expect.any(Number) }, - ]); - - bus.publish( - new PermissionApprovalResolved({ agentId: 'main', - id: 'approval_1', - sessionId: 's', - turnId: 1, - toolCallId: 'tc-1', - toolName: 'Bash', - action: 'run', - toolInput: {}, - display: { kind: 'command', command: 'ls' }, - decision: 'approved', - }), - ); - expect(view.state().turn?.pendingApprovals).toEqual([]); - }); - - it('falls back to the tool call id when the approval event carries no interaction id', () => { - const { bus, view } = harness(); - - bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - bus.publish( - new PermissionApprovalRequested({ agentId: 'main', - sessionId: 's', - turnId: 1, - toolCallId: 'tc-1', - toolName: 'Bash', - action: 'run', - toolInput: {}, - display: { kind: 'command', command: 'ls' }, - }), - ); - expect(view.state().turn?.pendingApprovals).toEqual([ - { approvalId: 'tc-1', toolCallId: 'tc-1', since: expect.any(Number) }, - ]); - }); -}); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index c77a13030de..d854273a57a 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -2654,7 +2654,7 @@ describe('FullCompaction', () => { expect(events).toContainEqual( expect.objectContaining({ event: 'turn.ended', - args: expect.objectContaining({ turnId: 1, reason: 'completed' }), + args: expect.objectContaining({ turnId: 2, reason: 'completed' }), }), ); await ctx.expectResumeMatches(); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 488bc7c28ba..62b34b3a38a 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -117,7 +117,6 @@ function createRequester( maxContextSize: 1000, alwaysThinking: false, providerName: 'p', - authProvider: { getAuth: async () => undefined }, }; return { model, @@ -168,6 +167,7 @@ function createService( ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-code-llm-requester-test', options.env ?? {})); const thinkingLevel = options.thinkingLevel ?? 'off'; const profile: Partial = { + hasProvider: () => true, resolveModelContext: () => ({ modelAlias: 'm', modelCapabilities: capabilities, @@ -755,7 +755,6 @@ describe('AgentLLMRequesterService trace id', () => { maxContextSize: 1000, alwaysThinking: false, providerName: 'p', - authProvider: { getAuth: async () => undefined }, }; return { model, diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index a2447aa789b..dc1a68359e6 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -76,32 +76,25 @@ describe('Agent loop', () => { [wire] prompt.accepted { "agentId": "main", "promptId": "", "content": [ { "type": "text", "text": "Hello" } ], "time": "

hello

', '.html'], + ['application/json', '{"a":1}', '.json'], + ['application/example+json', '{"a":1}', '.json'], + ['application/xml', 'one', '.xml'], + ['application/example+xml', 'one', '.xml'], + ['application/yaml', 'item: one', '.yaml'], + ['application/example+yaml', 'item: one', '.yaml'], + ['application/javascript', 'const item = 1;', '.js'], + ['application/toml', 'item = 1', '.toml'], + ['application/x-www-form-urlencoded', 'item=one', '.txt'], + ['text/x-example', 'example text', '.txt'], + ])('preserves %s blobs with a readable text extension', async (mimeType, body, extension) => { + const bytes = Buffer.from(body); + const result = await mcpResultToExecutableOutput({ + isError: false, + content: [{ type: 'resource', resource: { + uri: 'example://text', mimeType, blob: bytes.toString('base64'), + } }], + }, 'mcp__example__text', { attachmentStore: store }); + const encoded = /Original attachment saved at: ("[^\n]+")/.exec(modelText(result))?.[1]; + expect(encoded).toBeDefined(); + const path = JSON.parse(encoded!) as string; + expect(path.endsWith(extension)).toBe(true); + const saved = await readFile(path); + expect(saved.equals(bytes)).toBe(true); + expect(detectFileType(path, saved).kind).toBe('text'); + }); + + it('saves uncompressed SVG as readable SVG text', async () => { + const bytes = Buffer.from(''); + const output = await mcpResultToExecutableOutput({ + isError: false, + content: [{ type: 'resource', resource: { + uri: 'example://drawing', mimeType: 'image/svg+xml', blob: bytes.toString('base64'), + } }], + }, 'mcp__example__drawing', { attachmentStore: store }); + const path = JSON.parse(/Original attachment saved at: ("[^\n]+")/.exec(modelText(output))![1]!) as string; + expect(path.endsWith('.svg')).toBe(true); + const saved = await readFile(path); + expect(saved.equals(bytes)).toBe(true); + expect(detectFileType(path, saved).kind).toBe('text'); + }); + + it.each([true, false])('stops attachment persistence when cancellation is already triggered=%s', async (alreadyAborted) => { + const controller = new AbortController(); + const reason = new Error('attachment import canceled'); + const storage = ix.get(IFileSystemStorageService); + const writeStream = storage.writeStream.bind(storage); + const writes = vi.spyOn(storage, 'writeStream').mockImplementation(async (scope, key, source, options) => { + expect(options?.signal).toBe(controller.signal); + controller.abort(reason); + return writeStream(scope, key, source, options); + }); + if (alreadyAborted) controller.abort(reason); + await expect(mcpResultToExecutableOutput({ + isError: false, + content: [1, 2, 3].map((i) => ({ type: 'resource', resource: { + uri: `example://file/${String(i)}`, blob: Buffer.from(`file ${String(i)}`).toString('base64'), + } })), + }, 'mcp__example__files', { attachmentStore: store, signal: controller.signal })).rejects.toBe(reason); + expect(writes).toHaveBeenCalledTimes(alreadyAborted ? 0 : 1); + }); + it('keeps a same-size copy without re-reading the stream', async () => { await store.materialize(input()); const again = await store.materialize( diff --git a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts index 90b53730d18..9fd281082be 100644 --- a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts +++ b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts @@ -1,4 +1,5 @@ import * as posixPath from 'node:path/posix'; +import { Readable } from 'node:stream'; import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm-adapter/contract/capability'; import type { ContentPart } from '#human/llm/message'; @@ -28,6 +29,11 @@ import { } from '#/agent/media/image-compress'; import { createVideoUploader, registerMediaTools } from '#/agent/media/registerMediaTools'; import { AgentMediaToolsRegistrar } from '#/agent/media/mediaToolsRegistrar'; +import type { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; +import { SessionMediaStoreService } from '#/agent/media/sessionMediaStoreService'; +import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { makeSessionContext } from '#/session/sessionContext/sessionContext'; import { AgentStateService } from '#/agent/state/agentStateService'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; import { @@ -218,7 +224,7 @@ async function execute( tool: ReadMediaFileTool, args: ReadMediaFileInput, ): Promise { - const execution = tool.resolveExecution(args); + const execution = await tool.resolveExecution(args); if (!('execute' in execution)) { return execution; } @@ -435,7 +441,7 @@ describe('ReadMediaFileTool', () => { expect(result.isError).toBe(false); expect(vi.mocked(fs.readBytes)).toHaveBeenCalledTimes(2); - expect(vi.mocked(fs.readBytes)).toHaveBeenLastCalledWith('/workspace/large.png'); + expect(vi.mocked(fs.readBytes)).toHaveBeenLastCalledWith('/workspace/large.png', undefined); }); it('returns external preprocessing guidance before loading an oversized region source', async () => { @@ -862,6 +868,7 @@ describe('AgentMediaToolsRegistrar', () => { function createRegistrarHarness( files: Record = {}, providerTypes: Record = {}, + attachmentStore?: ISessionMediaStore, ) { const registry = new AgentToolRegistryService(); const eventBus = new EventBusService(); @@ -903,7 +910,10 @@ describe('AgentMediaToolsRegistrar', () => { _serviceBrand: undefined, onDidChange: runtimeChanges.event, isAvailable: (required = []) => runtimeAvailable && baseRuntime.isAvailable(required), - inspect: () => baseRuntime.inspect(), + inspect: () => { + if (!runtimeAvailable) throw new Error('runtime unavailable'); + return baseRuntime.inspect(); + }, acquire: (required = []) => baseRuntime.acquire(required), }; const registrar = new AgentMediaToolsRegistrar( @@ -915,6 +925,8 @@ describe('AgentMediaToolsRegistrar', () => { workspaceCtx, recordingTelemetry([]), new AgentStateService(), + undefined, + attachmentStore, ); const bindModel = (alias: string, caps: ModelCapability): void => { state.alias = alias; @@ -1012,6 +1024,26 @@ describe('AgentMediaToolsRegistrar', () => { expect(registry.resolve('ReadMediaFile')).toBeInstanceOf(ReadMediaFileTool); }); + it('keeps session-image reads available while the workspace runtime is unavailable', async () => { + const storage = new InMemoryStorageService(); + const store = new SessionMediaStoreService(makeSessionContext({ + sessionId: 'session', workspaceId: 'workspace', cwd: '/workspace', + sessionDir: '/session', sessionScope: 'session', + }), storage, new JsonAtomicDocumentStore(storage)); + const bytes = Buffer.from(await new Jimp({ width: 32, height: 32, color: 0x3366ccff }).getBuffer('image/png')); + await store.materialize({ fileId: 'f_picture', name: 'picture.png', mimeType: 'image/png', size: bytes.length, stream: () => Readable.from([bytes]) }); + const { registry, bindModel, setRuntimeAvailable } = createRegistrarHarness({}, {}, store); + bindModel('vision-model', capabilities({ image_in: true, video_in: false })); + setRuntimeAvailable(false); + const tool = registry.resolve('ReadMediaFile'); + expect(tool).toBeDefined(); + const execution = await tool!.resolveExecution({ path: 'kimi-file://f_picture' }); + if (execution.isError === true) throw new Error('expected runnable attachment read'); + const result = await execution.execute({ turnId: 1, toolCallId: 'image', signal: new AbortController().signal }); + expect(result.isError).not.toBe(true); + expect(outputParts(result).some((part) => part.type === 'image_url')).toBe(true); + }); + it('swaps the tool instance when the model alias changes', () => { const { registry, bindModel } = createRegistrarHarness(); bindModel('vision-a', capabilities({ image_in: true, video_in: true })); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 8587f91c376..a0827925303 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -274,13 +274,11 @@ describe('AgentPermissionPolicyService chain', () => { ['systemctl poweroff', 'systemctl poweroff'], ['systemctl --user reboot', 'systemctl reboot'], ['bash -c "shutdown now"', 'shutdown'], - ['rm -rf /tmp/build', 'rm -rf'], + ['rm -rf /tmp/build /root', 'rm -rf'], ['rm -fr dir', 'rm -rf'], ['rm -r -f dir', 'rm -rf'], ['rm -R --force dir', 'rm -rf'], - ['rm --recursive --force dir', 'rm -rf'], ['rm -rfv dir', 'rm -rf'], - ['sudo rm -rf dir', 'rm -rf'], ['sudo -u root rm --recursive --force dir', 'rm -rf'], ['echo ok && rm -rf dir', 'rm -rf'], ['env rm -rf dir', 'rm -rf'], @@ -311,6 +309,21 @@ describe('AgentPermissionPolicyService chain', () => { }); }); + it.each(['rm -rf /tmp/build', 'rm -rf /temp/cache'])( + 'approves `%s` in yolo mode', + async (command) => { + mode = 'yolo'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command, timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'yolo-mode-approve', + result: { kind: 'approve' }, + }); + }, + ); + it.each([ 'init 3', 'dd if=/dev/zero of=/dev/null bs=1M count=1', diff --git a/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts b/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts index eee7d1517ca..aa57f75c8c2 100644 --- a/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts +++ b/packages/agent-core-v2/test/agent/profile/compact-threshold.test.ts @@ -72,7 +72,6 @@ function createTestModel(): Model { alwaysThinking: false, providerType: 'kimi', providerName: 'kimi', - authProvider: { getAuth: async () => undefined }, }; } @@ -86,7 +85,7 @@ function createModelCatalogStub(model: Model): IModelCatalog { getRequester: () => { throw new Error('not exercised'); }, - inspect: () => { + generate: () => { throw new Error('not exercised'); }, ping: () => { @@ -418,7 +417,7 @@ describe('AgentProfileService.setCompactionTokenBudget telemetry (U11)', () => { spySvc.setCompactionTokenBudget(120); const matching = track2Calls.filter(([event]) => event === 'compaction_token_budget_override'); expect(matching.length).toBeGreaterThan(0); - const last = matching[matching.length - 1] as [string, Record]; + const last = matching.at(-1) as [string, Record]; const [event, payload] = last; expect(event).toBe('compaction_token_budget_override'); expect(payload['action']).toBe('set'); @@ -431,7 +430,7 @@ describe('AgentProfileService.setCompactionTokenBudget telemetry (U11)', () => { spySvc.setCompactionTokenBudget(undefined); const matching = track2Calls.filter(([event]) => event === 'compaction_token_budget_override'); expect(matching.length).toBeGreaterThanOrEqual(2); - const last = matching[matching.length - 1] as [string, Record]; + const last = matching.at(-1) as [string, Record]; const [event, payload] = last; expect(event).toBe('compaction_token_budget_override'); expect(payload['action']).toBe('clear'); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 2947c9c3e1c..3a3a3331e4d 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -92,7 +92,6 @@ function createTestModel( alwaysThinking: false, providerType, providerName: 'kimi', - authProvider: { getAuth: async () => undefined }, }; } @@ -107,7 +106,7 @@ function createModelCatalogStub(models: Readonly> = {}): I getRequester: () => { throw new Error('not exercised'); }, - inspect: () => { + generate: () => { throw new Error('not exercised'); }, ping: () => { diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 2ee82e36e29..33547714859 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -287,15 +287,37 @@ describe('AgentTaskService', () => { } } - it('enqueues a terminal notification for a finished detached task', async () => { - const svc = ix.get(IAgentTaskService); + it('enqueues a terminal notification for a finished detached task, but not when suppression arms mid-build', async () => { + let armOnRead = false; + let svc!: IAgentTaskService; + ix.stub(IFileSystemStorageService, { + read: async () => { + if (armOnRead) await svc.suppressAllTerminalNotifications(); + return undefined; + }, + readStream: async function* () {}, + write: async () => {}, + writeStream: async () => {}, + append: async () => {}, + list: async () => [], + delete: async () => {}, + flush: async () => {}, + }); + svc = ix.get(IAgentTaskService); const taskId = svc.registerTask(outputtingTask('done\n')); await svc.wait(taskId, 1000); const loop = stubLoop(); await waitForCondition(() => loop.hasPendingRequests()); - expect(loop.hasPendingRequests()).toBe(true); + + loop.drainNextBatch({ append: () => {} }); + armOnRead = true; + const second = svc.registerTask(outputtingTask('done\n')); + await svc.wait(second, 1000); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(loop.hasPendingRequests()).toBe(false); }); it('markTasksDeliveredViaWait suppresses the automatic terminal notification', async () => { @@ -317,7 +339,7 @@ describe('AgentTaskService', () => { expect(states.get(taskNotificationDeliveryKey)).toContain(deliveryKey); }); - it('aborts an already-enqueued terminal notification when the task is marked delivered via wait', async () => { + it('aborts an already-enqueued terminal notification when the task is marked delivered via wait or suppression arms', async () => { const svc = ix.get(IAgentTaskService); const taskId = svc.registerTask(outputtingTask('done\n')); @@ -329,6 +351,15 @@ describe('AgentTaskService', () => { svc.markTasksDeliveredViaWait([{ taskId, status: 'completed' }]); expect(loop.hasPendingRequests()).toBe(false); + + const second = svc.registerTask(outputtingTask('done\n')); + await svc.wait(second, 1000); + await waitForCondition(() => loop.hasPendingRequests()); + expect(loop.hasPendingRequests()).toBe(true); + + await svc.suppressAllTerminalNotifications(); + + expect(loop.hasPendingRequests()).toBe(false); }); it('suppresses only the notification whose status was reported via wait', async () => { @@ -507,26 +538,25 @@ describe('AgentTaskService', () => { const first = svc.registerTask(fakeProcessTask()); const second = svc.registerTask(fakeProcessTask()); + await svc.suppressAllTerminalNotifications(); + const third = svc.registerTask(fakeProcessTask()); + const stopped = await svc.stopAllOnExit('Session closed'); - expect(stopped.map((info) => info.taskId).toSorted()).toEqual([first, second].toSorted()); - for (const taskId of [first, second]) { + expect(stopped.map((info) => info.taskId).toSorted()).toEqual( + [first, second, third].toSorted(), + ); + for (const taskId of [first, second, third]) { const info = svc.getTask(taskId); expect(info?.status).toBe('killed'); expect(info?.stopReason).toBe('Session closed'); expect(info?.terminalNotificationSuppressed).toBe(true); - const persisted = writes.filter((write) => write.taskId === taskId); - expect( - persisted.some( - (write) => - write.status === 'running' && write.terminalNotificationSuppressed === true, - ), - ).toBe(true); - expect(persisted.at(-1)).toMatchObject({ + expect(writes.filter((write) => write.taskId === taskId).at(-1)).toMatchObject({ status: 'killed', terminalNotificationSuppressed: true, }); } + expect(stubLoop().hasPendingRequests()).toBe(false); }); it('stopAllOnExit does not persist a foreground-only task', async () => { @@ -544,7 +574,7 @@ describe('AgentTaskService', () => { }); }); - it('stopAllOnExit still stops tasks when suppression persistence fails', async () => { + it('stopAllOnExit still stops tasks when persistence fails', async () => { let writes = 0; ix.stub(IAtomicDocumentStore, { get: async () => undefined, @@ -566,7 +596,7 @@ describe('AgentTaskService', () => { expect(svc.getTask(second)?.status).toBe('killed'); }); - it('stopAllOnExit leaves tasks running when keepAliveOnExit is set', async () => { + it('stopAllOnExit leaves tasks running and suppresses in flight without persisting the marker when keepAliveOnExit is set', async () => { stubTaskConfig({ keepAliveOnExit: true }); const svc = ix.get(IAgentTaskService); const taskId = svc.registerTask(fakeProcessTask()); @@ -577,6 +607,10 @@ describe('AgentTaskService', () => { expect(svc.getTask(taskId)?.status).toBe('running'); await svc.stop(taskId); + + expect(svc.getTask(taskId)?.status).toBe('killed'); + expect(svc.getTask(taskId)?.terminalNotificationSuppressed).toBeUndefined(); + expect(stubLoop().hasPendingRequests()).toBe(false); }); it('dispose aborts live tasks as a last resort', async () => { @@ -643,7 +677,10 @@ describe('AgentTaskService', () => { expect(forceStop).not.toHaveBeenCalled(); }); - it('scope disposal leaves a process running when keepAliveOnExit is set', async () => { + it('scope disposal leaves a process running when keepAliveOnExit is set, and its late settle stays silent after deactivation', async () => { + const { records } = capturingWire(); + const track2 = vi.fn(); + ix.stub(ITelemetryService, { track2 }); stubTaskConfig({ keepAliveOnExit: true }); const stdout = new Readable({ read() {} }); const stderr = new Readable({ read() {} }); @@ -662,7 +699,8 @@ describe('AgentTaskService', () => { dispose: vi.fn().mockResolvedValue(undefined), } as unknown as IHostProcess; const svc = ix.get(IAgentTaskService); - svc.registerTask(new ProcessTask(proc, 'keep-running', 'long-running process')); + const taskId = svc.registerTask(new ProcessTask(proc, 'keep-running', 'long-running process')); + const agentContext = ix.get(IAgentScopeContext).agentContext; await Promise.resolve(); disposables.dispose(); @@ -671,10 +709,18 @@ describe('AgentTaskService', () => { expect(proc.kill).not.toHaveBeenCalled(); expect(proc.dispose).not.toHaveBeenCalled(); + eventBus.deactivateAgent(agentContext); stdout.push(null); stderr.push(null); resolveWait(0); - await Promise.resolve(); + await waitForCondition(() => svc.getTask(taskId)?.status === 'completed'); + + expect(svc.getTask(taskId)?.status).toBe('completed'); + expect(records.filter((record) => record['type'] === 'task.terminated')).toHaveLength(0); + expect(track2.mock.calls.map(([event]) => event)).toEqual([ + 'background_task_created', + 'background_task_completed', + ]); }); it('stop requests force-stop when killGracePeriodMs is zero', async () => { diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 2d64cbfb930..7b095e4567a 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -1,5 +1,6 @@ import { PassThrough, Readable, type Writable } from 'node:stream'; +import { createControlledPromise } from '@antfu/utils'; import { describe, expect, it, vi } from 'vitest'; import { @@ -32,6 +33,10 @@ import { SubagentTask } from '#/agent/tools/agent/subagent-task'; import type { SubagentTaskInfo } from '#/agent/tools/agent/subagent-task'; import { IWaitForTool } from '#/agent/tools/task/task-wait/task-wait'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { ToolProgress } from '#/agent/toolExecutor/toolExecutorEvents'; +import { IEventBus } from '#/app/event/eventBus'; import { executeTool } from '../../../tools/fixtures/execute-tool'; import { recordingTelemetry, type TelemetryRecord } from '../../../app/telemetry/stubs'; import { stubFlag } from '../../../app/flag/stubs'; @@ -195,6 +200,11 @@ class FakeTaskService implements IAgentTaskService { } as AgentTaskInfo; } + async suppressAllTerminalNotifications(): Promise { + const active = this.list(true).filter((info) => info.detached === true); + await Promise.all(active.map((info) => this.suppressTerminalNotification(info.taskId))); + } + markTasksDeliveredViaWait(tasks: readonly AgentTaskWaitDelivery[]): void { this.waitDeliveries.push(tasks); } @@ -1134,6 +1144,192 @@ describe('WaitForTool (harness)', () => { throw new Error(`Timed out waiting for task to terminate: ${taskId}`); } + it.each(['specific', 'any'] as const)('steers out of a running %s wait without losing tool history or stopping the background task', async (target) => { + const ctx = createTestAgent(); + const slow = controllableProcess(); + try { + await ctx.restorePersisted(); + ctx.get(IAgentProfileService).update({ activeToolNames: ['TaskList', 'WaitFor'] }); + const tasks = ctx.get(IAgentTaskService); + const taskId = tasks.registerTask(new ProcessTask(slow.proc, 'sleep 60', 'background work')); + ctx.mockNextResponse( + { type: 'function', id: 'list-before-wait', name: 'TaskList', arguments: '{}' }, + { type: 'function', id: 'wait-for-task', name: 'WaitFor', arguments: JSON.stringify({ timeout: 600, task_id: target === 'specific' ? taskId : undefined }) }, + ); + ctx.mockNextResponse({ type: 'text', text: 'Handling the new request.' }); + + const waiting = ctx.once('tool.progress'); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Wait for the background work.' }] }); + await waiting; + await ctx.rpc.steer({ input: [{ type: 'text', text: 'Handle this new request first.' }] }); + + await vi.waitFor(() => { + expect(ctx.llmCalls).toHaveLength(2); + }, { timeout: 1_000 }); + const history = ctx.llmCalls[1]!.history; + expect(history.filter((message) => message.role === 'tool')).toMatchObject([ + { toolCallId: 'list-before-wait', content: [{ type: 'text', text: expect.stringContaining(taskId) }] }, + { toolCallId: 'wait-for-task', content: [{ type: 'text', text: expect.stringContaining('wait_status: interrupted') }] }, + ]); + expect(history.at(-1)).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'Handle this new request first.' }], + }); + expect(ctx.allEvents).not.toContainEqual(expect.objectContaining({ + event: 'tool.result', + args: expect.objectContaining({ toolCallId: 'wait-for-task', isError: true }), + })); + expect(tasks.getTask(taskId)?.status).toBe('running'); + expect(slow.proc.kill).not.toHaveBeenCalled(); + await ctx.get(IAgentLoopService).settled(); + ctx.mockNextResponse({ type: 'text', text: 'The background work has finished.' }); + const notified = ctx.once('task.notified'); + slow.resolveWait(0); + await notified; + await ctx.get(IAgentLoopService).settled(); + expect(tasks.getTask(taskId)?.status).toBe('completed'); + expect(ctx.allEvents.filter((event) => event.event === 'task.notified')).toHaveLength(1); + await ctx.expectResumeMatches(); + } finally { + slow.resolveWait(0); + await ctx.dispose(); + } + }); + + it.each(['before-request', 'before-tool'] as const)('interrupts every wait after %s steering and can wait again after consuming the new input', async (timing) => { + const ctx = createTestAgent(); + const slow = controllableProcess(); + try { + await ctx.restorePersisted(); + ctx.get(IAgentProfileService).update({ activeToolNames: ['WaitFor'] }); + const tasks = ctx.get(IAgentTaskService); + const taskId = tasks.registerTask(new ProcessTask(slow.proc, 'sleep 60', 'background work')); + ctx.mockNextResponse( + { type: 'function', id: 'wait-specific', name: 'WaitFor', arguments: JSON.stringify({ timeout: 600, task_id: taskId }) }, + { type: 'function', id: 'wait-any', name: 'WaitFor', arguments: '{"timeout":600}' }, + ); + ctx.mockNextResponse({ + type: 'function', id: 'wait-again', name: 'WaitFor', + arguments: JSON.stringify({ timeout: 600, task_id: taskId }), + }); + ctx.mockNextResponse({ type: 'text', text: 'The background work has finished.' }); + const steer = async () => { + await ctx.rpc.steer({ input: [{ type: 'text', text: 'Check this message before waiting again.' }] }); + await ctx.rpc.steer({ input: [{ type: 'text', text: 'Keep the background task running.' }] }); + }; + if (timing === 'before-request') { + ctx.get(IAgentLoopService).hooks.onWillBeginStep.register('steer-before-request', async (event, next) => { + if (event.step === 1) await steer(); + await next(); + }); + } else { + ctx.get(IAgentToolExecutorService).onWillExecuteTool((event) => { + if (event.toolCall.id === 'wait-specific') event.waitUntil(steer()); + }); + } + const waitingAgain = createControlledPromise(); + ctx.get(IEventBus).subscribe(ToolProgress, (event) => { + if (event.toolCallId === 'wait-again') waitingAgain.resolve(); + }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Wait for the background work.' }] }); + await waitingAgain; + + expect(ctx.llmCalls[1]?.history.filter((message) => message.role === 'tool')).toMatchObject([ + { toolCallId: 'wait-specific', content: [{ text: expect.stringContaining('wait_status: interrupted') }] }, + { toolCallId: 'wait-any', content: [{ text: expect.stringContaining('wait_status: interrupted') }] }, + ]); + expect(ctx.llmCalls[1]?.history.at(-1)).toMatchObject({ + role: 'user', + content: [{ text: 'Check this message before waiting again.\n\nKeep the background task running.' }], + }); + expect(tasks.getTask(taskId)?.status).toBe('running'); + slow.resolveWait(0); + await ctx.get(IAgentLoopService).settled(); + expect(ctx.llmCalls).toHaveLength(3); + expect(ctx.llmCalls[2]?.history.find((message) => message.toolCallId === 'wait-again')).toMatchObject({ + content: [{ text: expect.stringContaining('wait_status: completed') }], + }); + expect(ctx.allEvents.filter((event) => event.event === 'task.notified')).toHaveLength(0); + await ctx.expectResumeMatches(); + } finally { + slow.resolveWait(0); + await ctx.dispose(); + } + }); + + it.each(['steer-first', 'completion-first'] as const)('reports task completion once when it races with steering (%s)', async (order) => { + const ctx = createTestAgent(); + const slow = controllableProcess(); + try { + await ctx.restorePersisted(); + ctx.get(IAgentProfileService).update({ activeToolNames: ['WaitFor'] }); + const tasks = ctx.get(IAgentTaskService); + const taskId = tasks.registerTask(new ProcessTask(slow.proc, 'sleep 60', 'background work')); + ctx.mockNextResponse({ + type: 'function', id: 'racing-wait', name: 'WaitFor', + arguments: JSON.stringify({ timeout: 600, task_id: taskId }), + }); + ctx.mockNextResponse({ type: 'text', text: 'Handling the new request.' }); + ctx.mockNextResponse({ type: 'text', text: 'The background work has finished.' }); + const waiting = ctx.once('tool.progress'); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Wait for the background work.' }] }); + await waiting; + + slow.pushOutput('BACKGROUND-RESULT'); + if (order === 'completion-first') slow.resolveWait(0); + const steered = ctx.rpc.steer({ input: [{ type: 'text', text: 'Handle the new request too.' }] }); + if (order === 'steer-first') slow.resolveWait(0); + await steered; + await waitForTerminal(tasks, taskId); + await vi.waitFor(() => { + const deliveries = ctx.context.get().filter((message) => + (message.origin?.kind === 'task' && message.origin.taskId === taskId) || + (message.toolCallId === 'racing-wait' && message.content.some((part) => + part.type === 'text' && part.text.includes('wait_status: completed'), + )), + ); + expect(deliveries).toHaveLength(1); + }); + await ctx.get(IAgentLoopService).settled(); + + const history = ctx.context.get(); + expect(await tasks.readOutput(taskId)).toBe('BACKGROUND-RESULT'); + expect(history.filter((message) => message.content.some((part) => + part.type === 'text' && part.text === 'Handle the new request too.', + ))).toHaveLength(1); + expect(tasks.getTask(taskId)?.status).toBe('completed'); + expect(slow.proc.kill).not.toHaveBeenCalled(); + await ctx.expectResumeMatches(); + } finally { + slow.resolveWait(0); + await ctx.dispose(); + } + }); + + it('still cancels a wait when execution is aborted together with steering', async () => { + const ctx = createTestAgent(); + const slow = controllableProcess(); + try { + const tasks = ctx.get(IAgentTaskService); + const taskId = tasks.registerTask(new ProcessTask(slow.proc, 'sleep 60', 'background work')); + const cancelled = new AbortController(); + const steered = new AbortController(); + const pending = executeTool(ctx.get(IWaitForTool), { + ...context('cancelled-wait', { timeout: 600, task_id: taskId }, cancelled.signal), + steerSignal: steered.signal, + }); + steered.abort(); + cancelled.abort(); + + await expect(pending).rejects.toThrow('Aborted'); + expect(tasks.getTask(taskId)?.status).toBe('running'); + } finally { + slow.resolveWait(0); + await ctx.dispose(); + } + }); + it('waits for a real registered task end-to-end and suppresses its notification', async () => { const records: TelemetryRecord[] = []; const loop = stubLoopWithHooks(); diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index 77837abed0c..fae87b35446 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -244,7 +244,7 @@ describe('AgentToolActivationService', () => { expect(requirements).toMatchObject({ Agent: ['process'], - Read: ['fs'], + Read: undefined, Write: ['fs'], Edit: ['fs'], Bash: ['process'], diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index ad611ba10ee..46a325c88f8 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough, Readable } from 'node:stream'; +import { Jimp } from 'jimp'; import type { ToolCall } from '#human/llm/message'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; @@ -38,6 +39,10 @@ import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; import { ToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncationService'; import { ReadTool } from '#/agent/tools/os/read/readTool'; +import { ReadMediaFileTool } from '#/agent/tools/read-media-file/readMediaFileTool'; +import { SessionMediaStoreService } from '#/agent/media/sessionMediaStoreService'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { makeSessionContext } from '#/session/sessionContext/sessionContext'; import { GlobTool } from '#/agent/tools/os/glob/globTool'; import { ReadInputSchema, type ReadInput } from '#/agent/tools/os/read/read'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; @@ -1048,6 +1053,8 @@ describe('truncation pipeline', () => { let homeDir: string; let readConfig: IConfigService; let globProcess: HostProcessService; + let attachmentStore: SessionMediaStoreService; + let mediaRuntime: IAgentRuntimeService; beforeEach(async () => { homeDir = await mkdtemp(join(tmpdir(), 'tool-executor-truncation-')); @@ -1060,7 +1067,13 @@ describe('truncation pipeline', () => { agentScope: 'sessions/workspace/session/agents/main', }), ); - truncationContainer.stub(IFileSystemStorageService, new FileStorageService(homeDir)); + const storage = new FileStorageService(homeDir); + truncationContainer.stub(IFileSystemStorageService, storage); + attachmentStore = new SessionMediaStoreService(makeSessionContext({ + sessionId: 'session', workspaceId: 'workspace', cwd: homeDir, + sessionDir: join(homeDir, 'sessions/workspace/session'), + sessionScope: 'sessions/workspace/session', + }), storage, new JsonAtomicDocumentStore(storage)); truncationContainer.set( IAgentToolResultTruncationService, new SyncDescriptor(ToolResultTruncationService), @@ -1085,12 +1098,14 @@ describe('truncation pipeline', () => { inspect: () => runtime, acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {} }), }; + mediaRuntime = binding; registry.register(new ReadTool( binding, stubWorkspaceContext(homeDir), { catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog, truncation, readConfig, + attachmentStore, )); registry.register(new GlobTool(binding, stubWorkspaceContext(homeDir), noopTelemetryService)); }); @@ -1212,6 +1227,149 @@ describe('truncation pipeline', () => { } }); + it('keeps the MCP attachment path visible after text spill without repeating the remote call', async () => { + const bytes = Buffer.from('%PDF-1.4\nexample report\n%%EOF'); + const client = { + async listTools() { return []; }, + callTool: vi.fn(async () => ({ + isError: false, + content: [ + { type: 'text', text: 'x'.repeat(100_000) }, + { type: 'resource', resource: { + uri: 'example://report', mimeType: 'application/pdf', blob: bytes.toString('base64'), + } }, + ], + })), + async ping() {}, + } satisfies MCPClient; + registry.register(createMcpTool('mcp__example__report', { + name: 'report', description: 'Example report', parameters: {}, + }, client, { attachmentStore }), { source: 'mcp' }); + const [result] = await execute([toolCall('report', 'mcp__example__report', {})]); + expect(result?.isError).not.toBe(true); + if (result === undefined) throw new Error('expected MCP result'); + const visible = renderToolResultForModel(result).map((part) => part.type === 'text' ? part.text : '').join('\n'); + expect(visible).toContain('output_path:'); + expect(visible.length).toBeLessThan(50_000); + const encodedPath = /Original attachment saved at: ("[^\n]+")/.exec(visible)?.[1]; + expect(encodedPath).toBeDefined(); + expect(readFileSync(JSON.parse(encodedPath!) as string).equals(bytes)).toBe(true); + expect(client.callTool).toHaveBeenCalledTimes(1); + }); + + it.each([0, 100_000])('bounds batch attachment notices and recovers every reference with %s text characters', async (textSize) => { + const originals = Array.from({ length: 150 }, (_, i) => Buffer.from(`%PDF-1.4\nreport ${String(i)}\n%%EOF`)); + const client: MCPClient = { + async listTools() { return []; }, + async callTool() { return { + isError: false, + content: [ + { type: 'text', text: `${'x'.repeat(100)}\n`.repeat(Math.ceil(textSize / 101)) }, + ...originals.map((bytes, i) => ({ type: 'resource', resource: { + uri: `example://report/${String(i)}`, mimeType: 'application/pdf', blob: bytes.toString('base64'), + } })), + ], + }; }, + async ping() {}, + }; + registry.register(createMcpTool('mcp__example__batch', { + name: 'batch', description: 'Example reports', parameters: {}, + }, client, { attachmentStore }), { source: 'mcp' }); + const [result] = await execute([toolCall('batch', 'mcp__example__batch', {})]); + if (result === undefined) throw new Error('expected batch output'); + const visible = renderToolResultForModel(result).map((part) => part.type === 'text' ? part.text : '').join('\n'); + expect(visible.length).toBeLessThan(50_000); + const encodedPath = /Attachment details reference: ("[^\n]+")/.exec(visible)?.[1]; + expect(encodedPath).toBeDefined(); + let args: ReadInput | undefined = { path: JSON.parse(encodedPath!) as string, max_chars: 8000 }; + let recovered = ''; + let pages = 0; + while (args !== undefined && pages < 30) { + const [read] = await execute([toolCall(`read_batch_${String(pages++)}`, 'Read', args)]); + expect(read?.isError).not.toBe(true); + if (typeof read?.output !== 'string') throw new Error('expected Read output'); + recovered += read.output.replaceAll(/^\d+\t/gm, '') + '\n'; + const next = /Next Read: (\{[^\n]*\})/.exec(read.note ?? '')?.[1]; + args = next === undefined ? undefined : ReadInputSchema.parse(JSON.parse(next)); + } + expect(args).toBeUndefined(); + expect(pages).toBeGreaterThan(1); + const paths = [...recovered.matchAll(/Original attachment saved at: ("[^\n]+")/g)].map((match) => JSON.parse(match[1]!) as string); + expect(paths).toHaveLength(150); + for (const [i, path] of paths.entries()) expect(readFileSync(path).equals(originals[i]!)).toBe(true); + }); + + it('resolves attachment references for media reads and exposes binary paths for converters', async () => { + const runtimeFs = mediaRuntime.inspect().fs!; + vi.spyOn(runtimeFs, 'stat').mockRejectedValue(new Error('client cannot access daemon storage')); + vi.spyOn(runtimeFs, 'readBytes').mockRejectedValue(new Error('client cannot access daemon storage')); + vi.spyOn(runtimeFs, 'readLines').mockImplementation(() => { + throw new Error('client cannot access daemon storage'); + }); + registry.register(new ReadMediaFileTool(mediaRuntime, { workspaceDir: homeDir, additionalDirs: [] }, { + image_in: true, video_in: false, audio_in: false, thinking: false, tool_use: true, + }, undefined, undefined, undefined, undefined, attachmentStore)); + const png = Buffer.from(await new Jimp({ width: 32, height: 32, color: 0x3366ccff }).getBuffer('image/png')); + const bytes = [png, Buffer.from('%PDF-1.4\nexample\n%%EOF')]; + const client: MCPClient = { + async listTools() { return []; }, + async callTool() { return { isError: false, content: bytes.map((data, i) => ({ type: 'resource', resource: { + uri: `example://file/${String(i)}`, mimeType: 'application/octet-stream', blob: data.toString('base64'), + } })) }; }, + async ping() {}, + }; + registry.register(createMcpTool('mcp__example__binary', { name: 'binary', description: 'Example files', parameters: {} }, client, { attachmentStore }), { source: 'mcp' }); + const [result] = await execute([toolCall('binary', 'mcp__example__binary', {})]); + if (result === undefined) throw new Error('expected MCP output'); + const text = renderToolResultForModel(result).map((part) => part.type === 'text' ? part.text : '').join('\n'); + const refs = [...text.matchAll(/Attachment reference: ("[^\n]+")/g)].map((match) => JSON.parse(match[1]!) as string); + const paths = [...text.matchAll(/Original attachment saved at: ("[^\n]+")/g)].map((match) => JSON.parse(match[1]!) as string); + expect(refs).toHaveLength(2); + const [image] = await execute([toolCall('read_image', 'ReadMediaFile', { path: refs[0] })]); + expect(image?.isError).not.toBe(true); + expect(Array.isArray(image?.output) && image.output.some((part) => part.type === 'image_url')).toBe(true); + if (image === undefined) throw new Error('expected image output'); + const imageText = renderToolResultForModel(image).map((part) => part.type === 'text' ? part.text : '').join('\n'); + const tagPath = //.exec(imageText)?.[1]; + expect(tagPath).toBe(refs[0]); + const [crop] = await execute([toolCall('read_crop', 'ReadMediaFile', { + path: tagPath, region: { x: 0, y: 0, width: 16, height: 16 }, + })]); + expect(crop?.isError).not.toBe(true); + const [pdf] = await execute([toolCall('read_pdf', 'Read', { path: refs[1] })]); + expect(pdf?.isError).toBe(true); + expect(pdf?.output).toContain(paths[1]); + expect(readFileSync(paths[1]!).equals(bytes[1]!)).toBe(true); + }); + + it('reads session text from its owner while workspace text still uses the runtime buffer', async () => { + const runtimeFs = mediaRuntime.inspect().fs!; + const clientRead = vi.spyOn(runtimeFs, 'readLines').mockImplementation(async function* () { + yield 'unsaved client buffer\n'; + }); + const workspaceFile = join(homeDir, 'workspace.txt'); + await writeFile(workspaceFile, 'disk content\n'); + const bytes = Buffer.from('session attachment\n'); + const client: MCPClient = { + async listTools() { return []; }, + async callTool() { return { isError: false, content: [{ type: 'resource', resource: { + uri: 'example://text', mimeType: 'text/plain', blob: bytes.toString('base64'), + } }] }; }, + async ping() {}, + }; + registry.register(createMcpTool('mcp__example__text', { name: 'text', description: 'Example text', parameters: {} }, client, { attachmentStore }), { source: 'mcp' }); + const [result] = await execute([toolCall('text', 'mcp__example__text', {})]); + if (result === undefined) throw new Error('expected MCP output'); + const text = renderToolResultForModel(result).map((part) => part.type === 'text' ? part.text : '').join('\n'); + const reference = JSON.parse(/Attachment reference: ("[^\n]+")/.exec(text)![1]!) as string; + const [attachment] = await execute([toolCall('read_attachment', 'Read', { path: reference })]); + expect(attachment?.output).toBe('1\tsession attachment'); + expect(clientRead).not.toHaveBeenCalled(); + const [workspace] = await execute([toolCall('read_workspace', 'Read', { path: workspaceFile })]); + expect(workspace?.output).toBe('1\tunsaved client buffer'); + expect(clientRead).toHaveBeenCalledTimes(1); + }); + it('recovers MCP structured records through spill and Read without repeating the MCP call', async () => { const structuredContent = { rows: Array.from({ length: 1200 }, (_, index) => ({ diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index b00dd468fbb..2fef0fb5132 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -222,13 +222,21 @@ class FakeLoopService implements IAgentLoopService { } status() { - return { state: 'idle' as const, pendingTurnIds: [], hasPendingRequests: false }; + return { state: 'idle' as const, pendingPromptIds: [], hasPendingRequests: false }; + } + + activitySnapshot() { + return {}; } cancel(_turnId?: number, _reason?: unknown): boolean { throw new Error('unused in this suite'); } + cancelQueued(_queueId: string, _reason?: unknown): boolean { + throw new Error('unused in this suite'); + } + tryAcquireQuiescence(): IDisposable | undefined { return toDisposable(() => {}); } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index ae569c93108..3df7d9c272a 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -323,21 +323,17 @@ describe('Agent config', () => { [wire] prompt.accepted { "agentId": "main", "promptId": "", "content": [ { "type": "text", "text": "Look up before config changes" } ], "time": "